From f14440b75b52419a5aea6777d7292f36578d0401 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:28:31 +0900 Subject: [PATCH 01/13] feat(host): show MCP call history in the tool dialog Keeps a bounded, newest-first record of completed MCP tool calls in McpActivity so the MCP dialog can show what an AI agent actually did with a plugin, not just what it could do. - McpCallRecord captures tool name, plugin, session, success and the epoch-millis completion time; McpActivity.recentCalls caps at 100 entries and drops the oldest. - toolInvocationFinished now takes whether the handler failed, and McpToolRegistrar reports a throwing handler as a failed call. - The MCP dialog gains Tools/History tabs. History lists the calls attributed to that plugin in the selected session, with a success or failure marker and the wall-clock time, and an empty state before any call is made. Search and the parameter pane keep their state across tab switches. --- .../composeResources/values-ja/strings.xml | 5 + .../main/composeResources/values/strings.xml | 5 + .../host/drawer/DrawerPluginItemUiState.kt | 3 + .../host/drawer/ExpandedToolingDrawerView.kt | 3 + .../host/drawer/PluginDrawerItemView.kt | 332 +++++++++++++----- .../host/drawer/ToolingScaffoldPresenter.kt | 12 +- .../server/DefaultMcpActivityRepository.kt | 22 +- .../DefaultMcpActivityRepositoryTest.kt | 88 +++++ .../jetwhale/host/mcp/McpToolRegistrar.kt | 7 +- .../host/mcp/DefaultMcpServerServiceTest.kt | 57 +++ .../host/mcp/FakeMcpActivityRepository.kt | 20 +- .../jetwhale/host/model/McpActivity.kt | 24 ++ .../host/model/McpActivityRepository.kt | 7 +- 13 files changed, 489 insertions(+), 96 deletions(-) create mode 100644 jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index 5c359c7d..aecd36ff 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -38,4 +38,9 @@ 一致するツールがありません パラメータ 必須 + ツール + 履歴 + MCP tools の呼び出し履歴はありません + 成功 + 失敗 diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index f9328213..8911b48a 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -38,4 +38,9 @@ No matching tools Parameters required + Tools + History + No MCP tool calls yet + Succeeded + Failed diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt index 43ecaa9b..26eada79 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt @@ -1,5 +1,6 @@ package com.kitakkun.jetwhale.host.drawer +import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginAvailability import com.kitakkun.jetwhale.host.model.PluginIconResource @@ -15,6 +16,8 @@ data class DrawerPluginItemUiState( val underAiControl: Boolean, /** The MCP tools this plugin exposes for the selected session; empty when it publishes none. */ val mcpTools: ImmutableList, + /** Completed MCP tool calls attributed to this plugin, newest first. */ + val mcpCallHistory: ImmutableList, ) { val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty() } diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt index 9d4dcd58..804d5980 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt @@ -170,6 +170,7 @@ fun ExpandedToolingDrawerView( selected = it.id == selectedPluginId, underAiControl = it.underAiControl, mcpTools = it.mcpTools, + mcpCallHistory = it.mcpCallHistory, onClick = { onClickPlugin(it) }, popupMenuContent = { dismiss -> DropdownMenuItem( @@ -242,6 +243,7 @@ fun ExpandedToolingDrawerView( selected = false, underAiControl = it.underAiControl, mcpTools = it.mcpTools, + mcpCallHistory = it.mcpCallHistory, onClick = { // do nothing }, @@ -288,6 +290,7 @@ fun ExpandedToolingDrawerView( selected = false, underAiControl = it.underAiControl, mcpTools = it.mcpTools, + mcpCallHistory = it.mcpCallHistory, onClick = { // do nothing }, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index b1934c29..c9da3e9c 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenu @@ -31,6 +33,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider @@ -50,12 +54,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.close +import com.kitakkun.jetwhale.host.mcp_history_empty +import com.kitakkun.jetwhale.host.mcp_history_failed +import com.kitakkun.jetwhale.host.mcp_history_succeeded import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available import com.kitakkun.jetwhale.host.mcp_tools_no_match import com.kitakkun.jetwhale.host.mcp_tools_parameters import com.kitakkun.jetwhale.host.mcp_tools_required import com.kitakkun.jetwhale.host.mcp_tools_search +import com.kitakkun.jetwhale.host.mcp_tools_tab_history +import com.kitakkun.jetwhale.host.mcp_tools_tab_tools +import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolParameterSummary import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginIconResource @@ -64,6 +74,9 @@ import com.kitakkun.jetwhale.host.puzzle_outlined import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun PluginDrawerItemView( @@ -72,6 +85,7 @@ fun PluginDrawerItemView( selected: Boolean, underAiControl: Boolean, mcpTools: ImmutableList, + mcpCallHistory: ImmutableList, activeIconResource: PluginIconResource?, inactiveIconResource: PluginIconResource?, onClick: () -> Unit, @@ -96,7 +110,12 @@ fun PluginDrawerItemView( modifier = Modifier.weight(1f, fill = false), ) if (mcpTools.isNotEmpty()) { - McpBadge(operating = underAiControl, pluginName = name, tools = mcpTools) + McpBadge( + operating = underAiControl, + pluginName = name, + tools = mcpTools, + callHistory = mcpCallHistory, + ) } } }, @@ -161,6 +180,7 @@ private fun McpBadge( operating: Boolean, pluginName: String, tools: ImmutableList, + callHistory: ImmutableList, ) { var showDialog by remember { mutableStateOf(false) } val shape = RoundedCornerShape(4.dp) @@ -197,16 +217,24 @@ private fun McpBadge( operating = operating, pluginName = pluginName, tools = tools, + callHistory = callHistory, onDismiss = { showDialog = false }, ) } } +/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */ +private enum class McpDialogTab { + Tools, + History, +} + @Composable private fun McpToolsDialog( operating: Boolean, pluginName: String, tools: ImmutableList, + callHistory: ImmutableList, onDismiss: () -> Unit, ) { Dialog(onDismissRequest = onDismiss) { @@ -233,98 +261,40 @@ private fun McpToolsDialog( } Spacer(Modifier.size(12.dp)) + var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) } + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == McpDialogTab.Tools, + onClick = { selectedTab = McpDialogTab.Tools }, + text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, + ) + Tab( + selected = selectedTab == McpDialogTab.History, + onClick = { selectedTab = McpDialogTab.History }, + text = { Text(stringResource(Res.string.mcp_tools_tab_history)) }, + ) + } + Spacer(Modifier.size(12.dp)) + + // Hoisted out of the pane so switching tabs and coming back keeps the search and the + // selected tool where the user left them. var query by remember { mutableStateOf("") } var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) } - val filtered = remember(query, tools) { - if (query.isBlank()) { - tools - } else { - tools.filter { - it.name.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - } - } - } - val selected = filtered.firstOrNull { it.name == selectedName } ?: filtered.firstOrNull() - Row(modifier = Modifier.weight(1f)) { - // Left pane: search + tool list. - Column(modifier = Modifier.width(260.dp)) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - placeholder = { Text(stringResource(Res.string.mcp_tools_search)) }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.size(8.dp)) - LazyColumn(modifier = Modifier.fillMaxHeight()) { - items(filtered, key = { it.name }) { tool -> - val isSelected = tool.name == selected?.name - Text( - text = tool.name.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, - ) - .clickable { selectedName = tool.name } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) - } - } - } - VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) - // Right pane: the selected tool's detail. - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (selected == null) { - Text( - text = stringResource(Res.string.mcp_tools_no_match), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = selected.name.substringAfterLast('.'), - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - ) - Text( - text = selected.name, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = selected.description, - style = MaterialTheme.typography.bodyMedium, - ) - if (selected.parameters.isNotEmpty()) { - Spacer(Modifier.size(4.dp)) - Text( - text = stringResource(Res.string.mcp_tools_parameters), - style = MaterialTheme.typography.labelLarge, - ) - selected.parameters.forEach { param -> McpParameterRow(param) } - } - } - } + when (selectedTab) { + McpDialogTab.Tools -> McpToolsPane( + tools = tools, + query = query, + onQueryChange = { query = it }, + selectedToolName = selectedName, + onSelectTool = { selectedName = it }, + modifier = Modifier.weight(1f), + ) + + McpDialogTab.History -> McpCallHistoryPane( + callHistory = callHistory, + modifier = Modifier.weight(1f), + ) } Row( @@ -340,6 +310,188 @@ private fun McpToolsDialog( } } +/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */ +@Composable +private fun McpToolsPane( + tools: ImmutableList, + query: String, + onQueryChange: (String) -> Unit, + selectedToolName: String?, + onSelectTool: (String) -> Unit, + modifier: Modifier, +) { + val filtered = remember(query, tools) { + if (query.isBlank()) { + tools + } else { + tools.filter { + it.name.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + } + } + } + val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull() + + Row(modifier = modifier) { + // Left pane: search + tool list. + Column(modifier = Modifier.width(260.dp)) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(Res.string.mcp_tools_search)) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + LazyColumn(modifier = Modifier.fillMaxHeight()) { + items(filtered, key = { it.name }) { tool -> + val isSelected = tool.name == selected?.name + Text( + text = tool.name.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + ) + .clickable { onSelectTool(tool.name) } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } + } + VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + // Right pane: the selected tool's detail. + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (selected == null) { + Text( + text = stringResource(Res.string.mcp_tools_no_match), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = selected.name.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = selected.name, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = selected.description, + style = MaterialTheme.typography.bodyMedium, + ) + if (selected.parameters.isNotEmpty()) { + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + selected.parameters.forEach { param -> McpParameterRow(param) } + } + } + } + } +} + +/** What an agent already did with this plugin, newest call first. */ +@Composable +private fun McpCallHistoryPane( + callHistory: ImmutableList, + modifier: Modifier, +) { + if (callHistory.isEmpty()) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.mcp_history_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(callHistory, key = { it.id }) { record -> McpCallHistoryRow(record) } + } +} + +@Composable +private fun McpCallHistoryRow(record: McpCallRecord) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val succeededLabel = stringResource( + if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, + ) + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = succeededLabel, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = succeededLabel, + style = MaterialTheme.typography.labelSmall, + color = if (record.succeeded) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + Text( + text = formatCallTime(record.finishedAtEpochMillis), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Wall-clock time of day, which is what the user can line up against their own actions. */ +private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + +private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis) + .atZone(ZoneId.systemDefault()) + .format(CallHistoryTimeFormatter) + @Composable private fun McpParameterRow(param: McpToolParameterSummary) { Column( diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt index 3c76187e..e1b0430e 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt @@ -72,7 +72,8 @@ fun toolingScaffoldPresenter( val setPluginEnabledMutation = rememberMutation(presenterContext.setPluginEnabledMutationKey) - val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation) { + val recentCalls = mcpActivity.recentCalls + val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation, recentCalls) { derivedStateOf { // Attribute the operation only when it targets the session the drawer is showing; // highlighting a plugin for some other device would be misleading. @@ -80,6 +81,12 @@ fun toolingScaffoldPresenter( ?.takeIf { it.sessionId != null && it.sessionId == selectedSession?.id } ?.pluginId + // Calls that named no session came from a tool that does not target one, so they belong + // to whichever session is on screen; the rest are shown only under their own session. + val callsForSelectedSession = recentCalls.filter { + it.sessionId == null || it.sessionId == selectedSession?.id + } + loadedPlugins.map { metaData -> val isInstalledOnAgent = selectedSession?.installedPlugins?.any { installed -> installed.pluginId == metaData.id } == true val isEnabledInSettings = enabledPluginIds.contains(metaData.id) @@ -102,6 +109,9 @@ fun toolingScaffoldPresenter( }, underAiControl = aiControlledPluginId == metaData.id, mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(), + mcpCallHistory = callsForSelectedSession + .filter { it.pluginId == metaData.id } + .toImmutableList(), ) }.toImmutableList() } diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt index b9b1e08b..30268cec 100644 --- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt +++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt @@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpActivityRepository +import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolInvocation import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -51,12 +52,31 @@ class DefaultMcpActivityRepository : McpActivityRepository { return invocationId } - override fun toolInvocationFinished(invocationId: Long) { + override fun toolInvocationFinished(invocationId: Long, failed: Boolean) { + // Sampled once outside the update block, which may re-run under contention. + val finishedAtEpochMillis = System.currentTimeMillis() activityFlow.update { activity -> + val finished = activity.runningInvocations.firstOrNull { it.id == invocationId } activity.copy( runningInvocations = activity.runningInvocations .filterNot { it.id == invocationId } .toImmutableList(), + recentCalls = if (finished == null) { + // The invocation is unknown here, so there is nothing to describe in history. + activity.recentCalls + } else { + val record = McpCallRecord( + id = finished.id, + toolName = finished.toolName, + pluginId = finished.pluginId, + sessionId = finished.sessionId, + succeeded = !failed, + finishedAtEpochMillis = finishedAtEpochMillis, + ) + (listOf(record) + activity.recentCalls) + .take(McpActivity.MAX_RECENT_CALLS) + .toImmutableList() + }, ) } } diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt new file mode 100644 index 00000000..ec6cc5bb --- /dev/null +++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt @@ -0,0 +1,88 @@ +package com.kitakkun.jetwhale.host.data.server + +import com.kitakkun.jetwhale.host.model.McpActivity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DefaultMcpActivityRepositoryTest { + + private val repository = DefaultMcpActivityRepository() + + @Test + fun `no calls are recorded before anything runs`() { + assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) + } + + @Test + fun `a completed call is recorded with its attribution`() { + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + repository.toolInvocationFinished(id, failed = false) + + val record = repository.activityFlow.value.recentCalls.single() + assertEquals("plugin.click", record.toolName) + assertEquals("com.example.plugin", record.pluginId) + assertEquals("session-1", record.sessionId) + assertTrue(record.succeeded) + } + + @Test + fun `a call is only recorded once it finishes`() { + repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + + assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) + } + + @Test + fun `a failed call is recorded as unsuccessful`() { + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + repository.toolInvocationFinished(id, failed = true) + + assertFalse(repository.activityFlow.value.recentCalls.single().succeeded) + } + + @Test + fun `history is ordered newest first`() { + listOf("first", "second", "third").forEach { toolName -> + val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1") + repository.toolInvocationFinished(id, failed = false) + } + + assertEquals( + listOf("third", "second", "first"), + repository.activityFlow.value.recentCalls.map { it.toolName }, + ) + } + + @Test + fun `history drops the oldest calls once the cap is reached`() { + val overflow = McpActivity.MAX_RECENT_CALLS + 5 + repeat(overflow) { index -> + val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1") + repository.toolInvocationFinished(id, failed = false) + } + + val recentCalls = repository.activityFlow.value.recentCalls + assertEquals(McpActivity.MAX_RECENT_CALLS, recentCalls.size) + assertEquals("tool-${overflow - 1}", recentCalls.first().toolName) + assertEquals("tool-${overflow - McpActivity.MAX_RECENT_CALLS}", recentCalls.last().toolName) + } + + @Test + fun `finishing an unknown invocation records nothing`() { + repository.toolInvocationFinished(invocationId = 404L, failed = false) + + assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) + } + + @Test + fun `clear drops the recorded history`() { + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + repository.toolInvocationFinished(id, failed = false) + + repository.clear() + + assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) + } +} diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index 9ceb2169..23e27337 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -79,10 +79,13 @@ class McpToolRegistrar( pluginId = resolvePluginId(request), sessionId = request.arguments?.get("sessionId")?.jsonContent, ) + // A thrown handler is the only failure signal available here, and it has to reach the + // repository before it propagates on to the MCP layer. + var failed = true try { - handler(request) + handler(request).also { failed = false } } finally { - activityRepository.toolInvocationFinished(invocationId) + activityRepository.toolInvocationFinished(invocationId, failed) } } } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index 39a4f15f..6028d16f 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -411,6 +411,63 @@ class DefaultMcpServerServiceTest { assertTrue(mcpActivityRepository.activityFlow.value.runningInvocations.isEmpty()) } + @Test + fun `a completed tool call is added to the recent-call history`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(FakeMcpTool("fake.recorded")), + ) + val recordedPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, recordedPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$recordedPort/sse") + try { + client.callTool( + "fake.recorded", + mapOf("pluginId" to "com.example.plugin", "sessionId" to "session-1"), + ) + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertEquals("fake.recorded", record.toolName) + assertEquals("com.example.plugin", record.pluginId) + assertEquals("session-1", record.sessionId) + assertTrue(record.succeeded) + } + + @Test + fun `a throwing tool call is recorded in history as a failure`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(FailingMcpTool("fake.failing")), + ) + val failingPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, failingPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$failingPort/sse") + try { + runCatching { client.callTool("fake.failing", emptyMap()) } + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertEquals("fake.failing", record.toolName) + assertFalse(record.succeeded) + } + @OptIn(ExperimentalJetWhaleApi::class) @Test fun `a plugin tool call is attributed to the plugin that owns it`() = runBlocking { diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt index d152da36..4567080b 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt @@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.mcp import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpActivityRepository +import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolInvocation import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -51,12 +52,29 @@ class FakeMcpActivityRepository : McpActivityRepository { return invocation.id } - override fun toolInvocationFinished(invocationId: Long) { + override fun toolInvocationFinished(invocationId: Long, failed: Boolean) { + val finishedAtEpochMillis = System.currentTimeMillis() activityFlow.update { activity -> + val finished = activity.runningInvocations.firstOrNull { it.id == invocationId } activity.copy( runningInvocations = activity.runningInvocations .filterNot { it.id == invocationId } .toImmutableList(), + recentCalls = if (finished == null) { + activity.recentCalls + } else { + val record = McpCallRecord( + id = finished.id, + toolName = finished.toolName, + pluginId = finished.pluginId, + sessionId = finished.sessionId, + succeeded = !failed, + finishedAtEpochMillis = finishedAtEpochMillis, + ) + (listOf(record) + activity.recentCalls) + .take(McpActivity.MAX_RECENT_CALLS) + .toImmutableList() + }, ) } } diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt index dc9f575c..e4087db0 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt @@ -17,6 +17,20 @@ data class McpToolInvocation( val sessionId: String?, ) +/** + * A finished MCP tool call, kept so the UI can show what an agent already did. + * + * [finishedAtEpochMillis] is wall-clock time in epoch milliseconds, taken when the call completed. + */ +data class McpCallRecord( + val id: Long, + val toolName: String, + val pluginId: String?, + val sessionId: String?, + val succeeded: Boolean, + val finishedAtEpochMillis: Long, +) + /** * Live view of what AI agents are doing through the MCP server, so the UI can tell the user when * something other than themselves is driving the debugger. @@ -33,15 +47,25 @@ data class McpActivity( val startedCount: Long, /** The most recently started tool call, kept so the UI can name and attribute it. */ val lastStartedInvocation: McpToolInvocation?, + /** + * Tool calls that have already completed, newest first, capped at [MAX_RECENT_CALLS]. This is a + * live troubleshooting aid rather than an audit log, so the oldest entries are dropped once the + * cap is reached. + */ + val recentCalls: ImmutableList, ) { val hasConnectedClient: Boolean get() = connectedClientCount > 0 companion object { + /** How many completed calls [recentCalls] keeps before dropping the oldest. */ + const val MAX_RECENT_CALLS = 100 + val Idle = McpActivity( connectedClientCount = 0, runningInvocations = persistentListOf(), startedCount = 0, lastStartedInvocation = null, + recentCalls = persistentListOf(), ) } } diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt index bc257c8d..a2b02ce1 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt @@ -23,7 +23,12 @@ interface McpActivityRepository { */ fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long - fun toolInvocationFinished(invocationId: Long) + /** + * Records the completion of a tool call and appends it to the recent-call history. + * + * @param failed true when the handler threw instead of returning a result. + */ + fun toolInvocationFinished(invocationId: Long, failed: Boolean) /** Drops all recorded activity, so a server restart does not inherit stale connection counts. */ fun clear() From ff7c43eaafcf68fc96c3109750b22dc4268221fc Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:48:02 +0900 Subject: [PATCH 02/13] feat(host): size the MCP dialog to the window and mark tool activity Grow the dialog with the window up to a readable maximum instead of a fixed 760x500, so tool descriptions and parameter lists fit without scrolling. In the tool list, show how many recorded calls each tool has and a pulsing accent dot on the tool an agent is running right now. Counts come from the retained history, so they describe the same calls the History tab lists. --- .../host/drawer/DrawerPluginItemUiState.kt | 2 + .../host/drawer/ExpandedToolingDrawerView.kt | 3 + .../host/drawer/PluginDrawerItemView.kt | 86 +++++++++++++++---- .../host/drawer/ToolingScaffoldPresenter.kt | 3 + 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt index 26eada79..d4eae91e 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt @@ -18,6 +18,8 @@ data class DrawerPluginItemUiState( val mcpTools: ImmutableList, /** Completed MCP tool calls attributed to this plugin, newest first. */ val mcpCallHistory: ImmutableList, + /** The tool an agent is running on this plugin right now, or null when none is in flight. */ + val runningMcpToolName: String?, ) { val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty() } diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt index 804d5980..53a63c8a 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt @@ -171,6 +171,7 @@ fun ExpandedToolingDrawerView( underAiControl = it.underAiControl, mcpTools = it.mcpTools, mcpCallHistory = it.mcpCallHistory, + runningMcpToolName = it.runningMcpToolName, onClick = { onClickPlugin(it) }, popupMenuContent = { dismiss -> DropdownMenuItem( @@ -244,6 +245,7 @@ fun ExpandedToolingDrawerView( underAiControl = it.underAiControl, mcpTools = it.mcpTools, mcpCallHistory = it.mcpCallHistory, + runningMcpToolName = it.runningMcpToolName, onClick = { // do nothing }, @@ -291,6 +293,7 @@ fun ExpandedToolingDrawerView( underAiControl = it.underAiControl, mcpTools = it.mcpTools, mcpCallHistory = it.mcpCallHistory, + runningMcpToolName = it.runningMcpToolName, onClick = { // do nothing }, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index c9da3e9c..8e2a24f2 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -10,14 +10,17 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -52,6 +55,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.close import com.kitakkun.jetwhale.host.mcp_history_empty @@ -86,6 +90,7 @@ fun PluginDrawerItemView( underAiControl: Boolean, mcpTools: ImmutableList, mcpCallHistory: ImmutableList, + runningMcpToolName: String?, activeIconResource: PluginIconResource?, inactiveIconResource: PluginIconResource?, onClick: () -> Unit, @@ -115,6 +120,7 @@ fun PluginDrawerItemView( pluginName = name, tools = mcpTools, callHistory = mcpCallHistory, + runningToolName = runningMcpToolName, ) } } @@ -181,6 +187,7 @@ private fun McpBadge( pluginName: String, tools: ImmutableList, callHistory: ImmutableList, + runningToolName: String?, ) { var showDialog by remember { mutableStateOf(false) } val shape = RoundedCornerShape(4.dp) @@ -218,6 +225,7 @@ private fun McpBadge( pluginName = pluginName, tools = tools, callHistory = callHistory, + runningToolName = runningToolName, onDismiss = { showDialog = false }, ) } @@ -235,14 +243,27 @@ private fun McpToolsDialog( pluginName: String, tools: ImmutableList, callHistory: ImmutableList, + runningToolName: String?, onDismiss: () -> Unit, ) { - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + // The platform default constrains the dialog to its content's size; tool descriptions and + // history need the room to grow with the window instead. + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { Surface(shape = MaterialTheme.shapes.large) { Column( modifier = Modifier - .width(760.dp) - .height(500.dp) + // Take most of the window so long descriptions and history are readable, but + // stop growing past a comfortable reading width on a large display. + .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION) + .sizeIn( + minWidth = 640.dp, + minHeight = 440.dp, + maxWidth = 1200.dp, + maxHeight = 860.dp, + ) .padding(20.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -288,6 +309,10 @@ private fun McpToolsDialog( onQueryChange = { query = it }, selectedToolName = selectedName, onSelectTool = { selectedName = it }, + runningToolName = runningToolName, + // Counts come from the retained history, so they cover the calls the + // dialog can actually show rather than all time. + callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() }, modifier = Modifier.weight(1f), ) @@ -318,6 +343,8 @@ private fun McpToolsPane( onQueryChange: (String) -> Unit, selectedToolName: String?, onSelectTool: (String) -> Unit, + runningToolName: String?, + callCounts: Map, modifier: Modifier, ) { val filtered = remember(query, tools) { @@ -334,7 +361,7 @@ private fun McpToolsPane( Row(modifier = modifier) { // Left pane: search + tool list. - Column(modifier = Modifier.width(260.dp)) { + Column(modifier = Modifier.width(300.dp)) { OutlinedTextField( value = query, onValueChange = onQueryChange, @@ -347,17 +374,11 @@ private fun McpToolsPane( LazyColumn(modifier = Modifier.fillMaxHeight()) { items(filtered, key = { it.name }) { tool -> val isSelected = tool.name == selected?.name - Text( - text = tool.name.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, + val isRunning = tool.name == runningToolName + val callCount = callCounts[tool.name] ?: 0 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) @@ -366,7 +387,38 @@ private fun McpToolsPane( ) .clickable { onSelectTool(tool.name) } .padding(horizontal = 10.dp, vertical = 8.dp), - ) + ) { + Text( + text = tool.name.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f, fill = false), + ) + if (callCount > 0) { + Text( + text = callCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (isRunning) { + // Same pulsing accent dot the drawer uses, so a tool being called right + // now is recognisable here too. + Box( + modifier = Modifier + .size(8.dp) + .alpha(aiActivityPulseAlpha(operating = true)) + .background(AiOperatingAccentColor, CircleShape), + ) + } + } } } } @@ -486,6 +538,8 @@ private fun McpCallHistoryRow(record: McpCallRecord) { } /** Wall-clock time of day, which is what the user can line up against their own actions. */ +private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f + private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis) diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt index e1b0430e..79dfc892 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt @@ -112,6 +112,9 @@ fun toolingScaffoldPresenter( mcpCallHistory = callsForSelectedSession .filter { it.pluginId == metaData.id } .toImmutableList(), + runningMcpToolName = activeInvocation + ?.takeIf { it.pluginId == metaData.id } + ?.toolName, ) }.toImmutableList() } From 7d530ecfc24905d3fab9285a86e60b18a2d027a4 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:56:37 +0900 Subject: [PATCH 03/13] fix(host): give call counts a badge and show the history total Right-align the per-tool call count as a badge instead of a bare number, so it reads clearly against the tool name, and take the accent fill and rotating ring while that tool is running. Repeat the badge next to the History tab so the number of recorded calls is visible without switching. --- .../host/drawer/PluginDrawerItemView.kt | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index 8e2a24f2..9a071749 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -292,7 +292,19 @@ private fun McpToolsDialog( Tab( selected = selectedTab == McpDialogTab.History, onClick = { selectedTab = McpDialogTab.History }, - text = { Text(stringResource(Res.string.mcp_tools_tab_history)) }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(stringResource(Res.string.mcp_tools_tab_history)) + if (callHistory.isNotEmpty()) { + // How many calls the history holds, so the count is visible + // without opening the tab. + McpToolCallCountBadge(count = callHistory.size, running = false) + } + } + }, ) } Spacer(Modifier.size(12.dp)) @@ -335,6 +347,35 @@ private fun McpToolsDialog( } } +/** + * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is + * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so + * "being called right now" reads the same way everywhere. + */ +@Composable +private fun McpToolCallCountBadge(count: Int, running: Boolean) { + val shape = RoundedCornerShape(6.dp) + Box( + modifier = Modifier + .clip(shape) + .background( + if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest, + shape, + ) + .then( + if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier, + ) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface, + ) + } +} + /** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */ @Composable private fun McpToolsPane( @@ -399,24 +440,11 @@ private fun McpToolsPane( } else { MaterialTheme.colorScheme.onSurface }, - modifier = Modifier.weight(1f, fill = false), + // Takes the free space so the count and dot sit against the right edge. + modifier = Modifier.weight(1f), ) - if (callCount > 0) { - Text( - text = callCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (isRunning) { - // Same pulsing accent dot the drawer uses, so a tool being called right - // now is recognisable here too. - Box( - modifier = Modifier - .size(8.dp) - .alpha(aiActivityPulseAlpha(operating = true)) - .background(AiOperatingAccentColor, CircleShape), - ) + if (callCount > 0 || isRunning) { + McpToolCallCountBadge(count = callCount, running = isRunning) } } } From d0aaca0b5ee9a1ee93c700161d7646de940d0fb7 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:59:38 +0900 Subject: [PATCH 04/13] feat(host): record and reveal MCP call arguments Every MCP tool call now carries the arguments it was made with into the call history, so the History tab can explain what an agent actually did rather than only which tool it reached for. Arguments are rendered to strings and shortened to `McpCallArgument.MAX_VALUE_LENGTH` characters at record time, so a call carrying a large body does not keep that body alive for as long as it stays in history. History rows expand on click to list the recorded `name = value` pairs, and a right-click context menu copies the full tool name, the arguments, or the whole call. --- .../composeResources/values-ja/strings.xml | 4 + .../main/composeResources/values/strings.xml | 4 + .../host/drawer/PluginDrawerItemView.kt | 219 ++++++++++++++---- .../server/DefaultMcpActivityRepository.kt | 12 +- .../DefaultMcpActivityRepositoryTest.kt | 73 +++++- .../jetwhale/host/mcp/McpToolRegistrar.kt | 7 + .../host/mcp/FakeMcpActivityRepository.kt | 12 +- .../jetwhale/host/model/McpActivity.kt | 34 +++ .../host/model/McpActivityRepository.kt | 10 +- 9 files changed, 325 insertions(+), 50 deletions(-) diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index aecd36ff..2e716662 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -43,4 +43,8 @@ MCP tools の呼び出し履歴はありません 成功 失敗 + 引数の表示を切り替え + ツール名をコピー + 引数をコピー + 詳細をコピー diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index 8911b48a..e9360c49 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -43,4 +43,8 @@ No MCP tool calls yet Succeeded Failed + Show or hide arguments + Copy tool name + Copy arguments + Copy details diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index 9a071749..0f82af6c 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -1,5 +1,8 @@ package com.kitakkun.jetwhale.host.drawer +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -27,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenu @@ -50,7 +54,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,9 +66,13 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.close +import com.kitakkun.jetwhale.host.mcp_history_copy_arguments +import com.kitakkun.jetwhale.host.mcp_history_copy_details +import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name import com.kitakkun.jetwhale.host.mcp_history_empty import com.kitakkun.jetwhale.host.mcp_history_failed import com.kitakkun.jetwhale.host.mcp_history_succeeded +import com.kitakkun.jetwhale.host.mcp_history_toggle_arguments import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available import com.kitakkun.jetwhale.host.mcp_tools_no_match @@ -512,56 +524,181 @@ private fun McpCallHistoryPane( } return } + // Expansion lives above the lazy list so a row keeps its state while scrolled out of view. + var expandedCallIds by remember { mutableStateOf(emptySet()) } LazyColumn( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - items(callHistory, key = { it.id }) { record -> McpCallHistoryRow(record) } + items(callHistory, key = { it.id }) { record -> + McpCallHistoryRow( + record = record, + expanded = record.id in expandedCallIds, + onToggleExpanded = { + expandedCallIds = if (record.id in expandedCallIds) { + expandedCallIds - record.id + } else { + expandedCallIds + record.id + } + }, + ) + } } } @Composable -private fun McpCallHistoryRow(record: McpCallRecord) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), +private fun McpCallHistoryRow( + record: McpCallRecord, + expanded: Boolean, + onToggleExpanded: () -> Unit, +) { + val succeededLabel = stringResource( + if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, + ) + val toggleArgumentsLabel = stringResource(Res.string.mcp_history_toggle_arguments) + val finishedAt = formatCallTime(record.finishedAtEpochMillis) + // Only calls that carry arguments have anything to reveal, so the rest stay plain rows. + val hasArguments = record.arguments.isNotEmpty() + val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } + val chevronRotation by animateFloatAsState(if (expanded) 90f else 0f) + + val clipboardManager = LocalClipboardManager.current + val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name) + val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments) + val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details) + + ContextMenuArea( + items = { + buildList { + add( + ContextMenuItem(copyToolNameLabel) { + clipboardManager.setText(AnnotatedString(record.toolName)) + }, + ) + if (hasArguments) { + add( + ContextMenuItem(copyArgumentsLabel) { + clipboardManager.setText(AnnotatedString(renderedArguments)) + }, + ) + } + add( + ContextMenuItem(copyDetailsLabel) { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = succeededLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + ), + ), + ) + }, + ) + } + }, ) { - val succeededLabel = stringResource( - if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, - ) - Icon( - imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, - contentDescription = succeededLabel, - tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp), - ) - Text( - text = record.toolName.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = succeededLabel, - style = MaterialTheme.typography.labelSmall, - color = if (record.succeeded) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.error - }, - ) - Text( - text = formatCallTime(record.finishedAtEpochMillis), - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (hasArguments) { + Modifier.clickable( + onClickLabel = toggleArgumentsLabel, + role = Role.Button, + onClick = onToggleExpanded, + ) + } else { + Modifier + }, + ) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (hasArguments) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = toggleArgumentsLabel, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(16.dp) + .rotate(chevronRotation), + ) + } else { + // Keeps rows without arguments aligned with the ones that show a chevron. + Spacer(Modifier.size(16.dp)) + } + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = succeededLabel, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = succeededLabel, + style = MaterialTheme.typography.labelSmall, + color = if (record.succeeded) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + Text( + text = finishedAt, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (hasArguments && expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 36.dp, end = 10.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + record.arguments.forEach { argument -> + Text( + text = "${argument.name} = ${argument.value}", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** Everything known about one call, in the order the row shows it. */ +private fun buildCallDetails( + toolName: String, + statusLabel: String, + finishedAt: String, + renderedArguments: String, +): String = buildString { + appendLine(toolName) + appendLine(statusLabel) + append(finishedAt) + if (renderedArguments.isNotEmpty()) { + appendLine() + append(renderedArguments) } } diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt index 30268cec..1da37880 100644 --- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt +++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt @@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpActivityRepository +import com.kitakkun.jetwhale.host.model.McpCallArgument import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolInvocation import dev.zacsweers.metro.AppScope @@ -34,13 +35,21 @@ class DefaultMcpActivityRepository : McpActivityRepository { } } - override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long { + override fun toolInvocationStarted( + toolName: String, + pluginId: String?, + sessionId: String?, + arguments: Map, + ): Long { val invocationId = nextInvocationId.incrementAndGet() val invocation = McpToolInvocation( id = invocationId, toolName = toolName, pluginId = pluginId, sessionId = sessionId, + arguments = arguments + .map { (name, value) -> McpCallArgument.truncating(name, value) } + .toImmutableList(), ) activityFlow.update { it.copy( @@ -72,6 +81,7 @@ class DefaultMcpActivityRepository : McpActivityRepository { sessionId = finished.sessionId, succeeded = !failed, finishedAtEpochMillis = finishedAtEpochMillis, + arguments = finished.arguments, ) (listOf(record) + activity.recentCalls) .take(McpActivity.MAX_RECENT_CALLS) diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt index ec6cc5bb..662026f4 100644 --- a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt +++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt @@ -1,6 +1,7 @@ package com.kitakkun.jetwhale.host.data.server import com.kitakkun.jetwhale.host.model.McpActivity +import com.kitakkun.jetwhale.host.model.McpCallArgument import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -17,7 +18,7 @@ class DefaultMcpActivityRepositoryTest { @Test fun `a completed call is recorded with its attribution`() { - val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) repository.toolInvocationFinished(id, failed = false) val record = repository.activityFlow.value.recentCalls.single() @@ -29,14 +30,14 @@ class DefaultMcpActivityRepositoryTest { @Test fun `a call is only recorded once it finishes`() { - repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) } @Test fun `a failed call is recorded as unsuccessful`() { - val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) repository.toolInvocationFinished(id, failed = true) assertFalse(repository.activityFlow.value.recentCalls.single().succeeded) @@ -45,7 +46,7 @@ class DefaultMcpActivityRepositoryTest { @Test fun `history is ordered newest first`() { listOf("first", "second", "third").forEach { toolName -> - val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1") + val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1", emptyMap()) repository.toolInvocationFinished(id, failed = false) } @@ -59,7 +60,7 @@ class DefaultMcpActivityRepositoryTest { fun `history drops the oldest calls once the cap is reached`() { val overflow = McpActivity.MAX_RECENT_CALLS + 5 repeat(overflow) { index -> - val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1") + val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1", emptyMap()) repository.toolInvocationFinished(id, failed = false) } @@ -76,9 +77,69 @@ class DefaultMcpActivityRepositoryTest { assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) } + @Test + fun `a completed call is recorded with the arguments it was made with`() { + val id = repository.toolInvocationStarted( + "plugin.click", + "com.example.plugin", + "session-1", + mapOf("sessionId" to "session-1", "x" to "100"), + ) + repository.toolInvocationFinished(id, failed = false) + + val record = repository.activityFlow.value.recentCalls.single() + assertEquals( + listOf( + McpCallArgument("sessionId", "session-1"), + McpCallArgument("x", "100"), + ), + record.arguments, + ) + } + + @Test + fun `a long argument value is truncated with an ellipsis`() { + val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH + 20) + val id = repository.toolInvocationStarted( + "plugin.type", + "com.example.plugin", + "session-1", + mapOf("text" to value), + ) + repository.toolInvocationFinished(id, failed = false) + + val recorded = repository.activityFlow.value.recentCalls.single().arguments.single() + assertEquals( + "a".repeat(McpCallArgument.MAX_VALUE_LENGTH) + McpCallArgument.TRUNCATION_MARKER, + recorded.value, + ) + } + + @Test + fun `an argument value at the cap is kept whole`() { + val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH) + val id = repository.toolInvocationStarted( + "plugin.type", + "com.example.plugin", + "session-1", + mapOf("text" to value), + ) + repository.toolInvocationFinished(id, failed = false) + + assertEquals(value, repository.activityFlow.value.recentCalls.single().arguments.single().value) + } + + @Test + fun `a call without arguments records an empty argument list`() { + val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = false) + + assertTrue(repository.activityFlow.value.recentCalls.single().arguments.isEmpty()) + } + @Test fun `clear drops the recorded history`() { - val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1") + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) repository.toolInvocationFinished(id, failed = false) repository.clear() diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index 23e27337..6073f3e0 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -78,6 +78,13 @@ class McpToolRegistrar( toolName = name, pluginId = resolvePluginId(request), sessionId = request.arguments?.get("sessionId")?.jsonContent, + // Every argument key is reported, including the ones the UI already shows as + // attribution, so history describes the call exactly as the agent made it. + arguments = request.arguments.orEmpty().mapValues { (_, value) -> + // Primitives render without the surrounding JSON quoting; objects and arrays + // fall back to their JSON form. + value.jsonContent ?: value.toString() + }, ) // A thrown handler is the only failure signal available here, and it has to reach the // repository before it propagates on to the MCP layer. diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt index 4567080b..f2192082 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt @@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.mcp import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpActivityRepository +import com.kitakkun.jetwhale.host.model.McpCallArgument import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolInvocation import kotlinx.collections.immutable.toImmutableList @@ -34,12 +35,20 @@ class FakeMcpActivityRepository : McpActivityRepository { } } - override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long { + override fun toolInvocationStarted( + toolName: String, + pluginId: String?, + sessionId: String?, + arguments: Map, + ): Long { val invocation = McpToolInvocation( id = nextInvocationId.incrementAndGet(), toolName = toolName, pluginId = pluginId, sessionId = sessionId, + arguments = arguments + .map { (name, value) -> McpCallArgument.truncating(name, value) } + .toImmutableList(), ) _recordedInvocations += invocation activityFlow.update { @@ -70,6 +79,7 @@ class FakeMcpActivityRepository : McpActivityRepository { sessionId = finished.sessionId, succeeded = !failed, finishedAtEpochMillis = finishedAtEpochMillis, + arguments = finished.arguments, ) (listOf(record) + activity.recentCalls) .take(McpActivity.MAX_RECENT_CALLS) diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt index e4087db0..8aa86327 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt @@ -3,6 +3,36 @@ package com.kitakkun.jetwhale.host.model import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +/** + * One argument a tool call was made with, already rendered for display. + * + * [value] is the argument rendered to a string and shortened to [MAX_VALUE_LENGTH] characters, so a + * call carrying a large body (a screenshot, an accessibility tree, ...) does not keep that body + * alive for as long as the call stays in history. + */ +data class McpCallArgument( + val name: String, + val value: String, +) { + companion object { + /** How many characters of an argument value are kept. */ + const val MAX_VALUE_LENGTH = 80 + + /** Appended to a [value] that was cut, so the UI does not have to mark it itself. */ + const val TRUNCATION_MARKER = "…" + + /** Builds an argument whose [value] is shortened to [MAX_VALUE_LENGTH] characters. */ + fun truncating(name: String, value: String): McpCallArgument = McpCallArgument( + name = name, + value = if (value.length <= MAX_VALUE_LENGTH) { + value + } else { + value.take(MAX_VALUE_LENGTH) + TRUNCATION_MARKER + }, + ) + } +} + /** * A single MCP tool call that is currently being executed on behalf of an AI agent. * @@ -15,6 +45,8 @@ data class McpToolInvocation( val toolName: String, val pluginId: String?, val sessionId: String?, + /** Every argument the call was made with, including `pluginId` and `sessionId`. */ + val arguments: ImmutableList, ) /** @@ -29,6 +61,8 @@ data class McpCallRecord( val sessionId: String?, val succeeded: Boolean, val finishedAtEpochMillis: Long, + /** Every argument the call was made with, including `pluginId` and `sessionId`. */ + val arguments: ImmutableList, ) /** diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt index a2b02ce1..a0eae452 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt @@ -19,9 +19,17 @@ interface McpActivityRepository { /** * Records the start of a tool call. * + * @param arguments the arguments the call was made with, already rendered to strings. Values are + * shortened to [McpCallArgument.MAX_VALUE_LENGTH] here, so nothing large is retained for as long + * as the call stays in history. * @return the invocation id to hand back to [toolInvocationFinished] once the call completes. */ - fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long + fun toolInvocationStarted( + toolName: String, + pluginId: String?, + sessionId: String?, + arguments: Map, + ): Long /** * Records the completion of a tool call and appends it to the recent-call history. From 39e33faf9802493de7227f34a56f0f02b13ad7aa Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:16:35 +0900 Subject: [PATCH 05/13] refactor(host): browse call history in two panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the Tools tab: a selectable list of calls on the left, and the selected call's full detail on the right — tool name, status, time, and each argument on its own labelled line. Replaces the inline expansion, which cramped long argument values into the row. The right pane also carries copy buttons alongside the existing row context menu. --- .../composeResources/values-ja/strings.xml | 2 +- .../main/composeResources/values/strings.xml | 2 +- .../host/drawer/PluginDrawerItemView.kt | 281 +++++++++++------- 3 files changed, 177 insertions(+), 108 deletions(-) diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index 2e716662..8feac7ca 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -43,8 +43,8 @@ MCP tools の呼び出し履歴はありません 成功 失敗 - 引数の表示を切り替え ツール名をコピー 引数をコピー 詳細をコピー + 引数なし diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index e9360c49..0e641462 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -43,8 +43,8 @@ No MCP tool calls yet Succeeded Failed - Show or hide arguments Copy tool name Copy arguments Copy details + No arguments diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index 0f82af6c..095f6608 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -71,8 +71,8 @@ import com.kitakkun.jetwhale.host.mcp_history_copy_details import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name import com.kitakkun.jetwhale.host.mcp_history_empty import com.kitakkun.jetwhale.host.mcp_history_failed +import com.kitakkun.jetwhale.host.mcp_history_no_arguments import com.kitakkun.jetwhale.host.mcp_history_succeeded -import com.kitakkun.jetwhale.host.mcp_history_toggle_arguments import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available import com.kitakkun.jetwhale.host.mcp_tools_no_match @@ -524,24 +524,147 @@ private fun McpCallHistoryPane( } return } - // Expansion lives above the lazy list so a row keeps its state while scrolled out of view. - var expandedCallIds by remember { mutableStateOf(emptySet()) } - LazyColumn( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp), + // Selection lives above the lazy list so it survives the row scrolling out of view. + var selectedCallId by remember { mutableStateOf(callHistory.first().id) } + val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first() + + Row(modifier = modifier) { + LazyColumn( + modifier = Modifier.width(300.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(callHistory, key = { it.id }) { record -> + McpCallHistoryRow( + record = record, + selected = record.id == selected.id, + onSelect = { selectedCallId = record.id }, + ) + } + } + VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + McpCallDetailPane( + record = selected, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } +} + +/** Right pane: everything recorded about the selected call, with copy actions. */ +@Composable +private fun McpCallDetailPane( + record: McpCallRecord, + modifier: Modifier, +) { + val statusLabel = stringResource( + if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, + ) + val finishedAt = formatCallTime(record.finishedAtEpochMillis) + val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(callHistory, key = { it.id }) { record -> - McpCallHistoryRow( - record = record, - expanded = record.id in expandedCallIds, - onToggleExpanded = { - expandedCallIds = if (record.id in expandedCallIds) { - expandedCallIds - record.id - } else { - expandedCallIds + record.id - } + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = record.toolName, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = null, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.labelMedium, + color = if (record.succeeded) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error }, ) + Text( + text = finishedAt, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + if (record.arguments.isEmpty()) { + Text( + text = stringResource(Res.string.mcp_history_no_arguments), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + record.arguments.forEach { argument -> + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = argument.name, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = argument.value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.size(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) }, + ) { + Text(stringResource(Res.string.mcp_history_copy_tool_name)) + } + if (record.arguments.isNotEmpty()) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) }, + ) { + Text(stringResource(Res.string.mcp_history_copy_arguments)) + } + } + TextButton( + onClick = { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = statusLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + ), + ), + ) + }, + ) { + Text(stringResource(Res.string.mcp_history_copy_details)) + } } } } @@ -549,18 +672,14 @@ private fun McpCallHistoryPane( @Composable private fun McpCallHistoryRow( record: McpCallRecord, - expanded: Boolean, - onToggleExpanded: () -> Unit, + selected: Boolean, + onSelect: () -> Unit, ) { - val succeededLabel = stringResource( + val statusLabel = stringResource( if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, ) - val toggleArgumentsLabel = stringResource(Res.string.mcp_history_toggle_arguments) val finishedAt = formatCallTime(record.finishedAtEpochMillis) - // Only calls that carry arguments have anything to reveal, so the rest stay plain rows. - val hasArguments = record.arguments.isNotEmpty() val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } - val chevronRotation by animateFloatAsState(if (expanded) 90f else 0f) val clipboardManager = LocalClipboardManager.current val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name) @@ -575,7 +694,7 @@ private fun McpCallHistoryRow( clipboardManager.setText(AnnotatedString(record.toolName)) }, ) - if (hasArguments) { + if (record.arguments.isNotEmpty()) { add( ContextMenuItem(copyArgumentsLabel) { clipboardManager.setText(AnnotatedString(renderedArguments)) @@ -588,7 +707,7 @@ private fun McpCallHistoryRow( AnnotatedString( buildCallDetails( toolName = record.toolName, - statusLabel = succeededLabel, + statusLabel = statusLabel, finishedAt = finishedAt, renderedArguments = renderedArguments, ), @@ -599,94 +718,44 @@ private fun McpCallHistoryRow( } }, ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)), + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .clickable(role = Role.Button, onClick = onSelect) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .then( - if (hasArguments) { - Modifier.clickable( - onClickLabel = toggleArgumentsLabel, - role = Role.Button, - onClick = onToggleExpanded, - ) - } else { - Modifier - }, - ) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (hasArguments) { - Icon( - imageVector = Icons.Default.KeyboardArrowRight, - contentDescription = toggleArgumentsLabel, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(16.dp) - .rotate(chevronRotation), - ) + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = statusLabel, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer } else { - // Keeps rows without arguments aligned with the ones that show a chevron. - Spacer(Modifier.size(16.dp)) - } - Icon( - imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, - contentDescription = succeededLabel, - tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp), - ) - Text( - text = record.toolName.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = succeededLabel, - style = MaterialTheme.typography.labelSmall, - color = if (record.succeeded) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.error - }, - ) - Text( - text = finishedAt, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (hasArguments && expanded) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 36.dp, end = 10.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - record.arguments.forEach { argument -> - Text( - text = "${argument.name} = ${argument.value}", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + Text( + text = finishedAt, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } - -/** Everything known about one call, in the order the row shows it. */ private fun buildCallDetails( toolName: String, statusLabel: String, From eb18026ebc97ac8d383e6acfe5b434f6b875575a Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:20:18 +0900 Subject: [PATCH 06/13] refactor(host): move the MCP tools dialog to its own file PluginDrawerItemView had grown to hold the drawer item, the MCP badge, the tool browser, the call history, and their context menus. Move everything from the dialog down into McpToolsDialog.kt so each file covers one screen concern. Code is unchanged apart from the visibility needed across the file boundary and the import split. --- .../jetwhale/host/drawer/McpToolsDialog.kt | 656 ++++++++++++++++++ .../host/drawer/PluginDrawerItemView.kt | 631 ----------------- 2 files changed, 656 insertions(+), 631 deletions(-) create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt new file mode 100644 index 00000000..03845d23 --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt @@ -0,0 +1,656 @@ +package com.kitakkun.jetwhale.host.drawer + +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.kitakkun.jetwhale.host.Res +import com.kitakkun.jetwhale.host.close +import com.kitakkun.jetwhale.host.mcp_history_copy_arguments +import com.kitakkun.jetwhale.host.mcp_history_copy_details +import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name +import com.kitakkun.jetwhale.host.mcp_history_empty +import com.kitakkun.jetwhale.host.mcp_history_failed +import com.kitakkun.jetwhale.host.mcp_history_no_arguments +import com.kitakkun.jetwhale.host.mcp_history_succeeded +import com.kitakkun.jetwhale.host.mcp_tool_executing +import com.kitakkun.jetwhale.host.mcp_tools_available +import com.kitakkun.jetwhale.host.mcp_tools_no_match +import com.kitakkun.jetwhale.host.mcp_tools_parameters +import com.kitakkun.jetwhale.host.mcp_tools_required +import com.kitakkun.jetwhale.host.mcp_tools_search +import com.kitakkun.jetwhale.host.mcp_tools_tab_history +import com.kitakkun.jetwhale.host.mcp_tools_tab_tools +import com.kitakkun.jetwhale.host.model.McpCallRecord +import com.kitakkun.jetwhale.host.model.McpToolParameterSummary +import com.kitakkun.jetwhale.host.model.McpToolSummary +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */ +internal enum class McpDialogTab { + Tools, + History, +} + +@Composable +internal fun McpToolsDialog( + operating: Boolean, + pluginName: String, + tools: ImmutableList, + callHistory: ImmutableList, + runningToolName: String?, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + // The platform default constrains the dialog to its content's size; tool descriptions and + // history need the room to grow with the window instead. + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(shape = MaterialTheme.shapes.large) { + Column( + modifier = Modifier + // Take most of the window so long descriptions and history are readable, but + // stop growing past a comfortable reading width on a large display. + .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION) + .sizeIn( + minWidth = 640.dp, + minHeight = 440.dp, + maxWidth = 1200.dp, + maxHeight = 860.dp, + ) + .padding(20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = pluginName, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource( + if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.size(12.dp)) + + var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) } + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == McpDialogTab.Tools, + onClick = { selectedTab = McpDialogTab.Tools }, + text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, + ) + Tab( + selected = selectedTab == McpDialogTab.History, + onClick = { selectedTab = McpDialogTab.History }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(stringResource(Res.string.mcp_tools_tab_history)) + if (callHistory.isNotEmpty()) { + // How many calls the history holds, so the count is visible + // without opening the tab. + McpToolCallCountBadge(count = callHistory.size, running = false) + } + } + }, + ) + } + Spacer(Modifier.size(12.dp)) + + // Hoisted out of the pane so switching tabs and coming back keeps the search and the + // selected tool where the user left them. + var query by remember { mutableStateOf("") } + var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) } + + when (selectedTab) { + McpDialogTab.Tools -> McpToolsPane( + tools = tools, + query = query, + onQueryChange = { query = it }, + selectedToolName = selectedName, + onSelectTool = { selectedName = it }, + runningToolName = runningToolName, + // Counts come from the retained history, so they cover the calls the + // dialog can actually show rather than all time. + callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() }, + modifier = Modifier.weight(1f), + ) + + McpDialogTab.History -> McpCallHistoryPane( + callHistory = callHistory, + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.close)) + } + } + } + } + } +} + +/** + * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is + * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so + * "being called right now" reads the same way everywhere. + */ +@Composable +internal fun McpToolCallCountBadge(count: Int, running: Boolean) { + val shape = RoundedCornerShape(6.dp) + Box( + modifier = Modifier + .clip(shape) + .background( + if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest, + shape, + ) + .then( + if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier, + ) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */ +@Composable +private fun McpToolsPane( + tools: ImmutableList, + query: String, + onQueryChange: (String) -> Unit, + selectedToolName: String?, + onSelectTool: (String) -> Unit, + runningToolName: String?, + callCounts: Map, + modifier: Modifier, +) { + val filtered = remember(query, tools) { + if (query.isBlank()) { + tools + } else { + tools.filter { + it.name.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + } + } + } + val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull() + + Row(modifier = modifier) { + // Left pane: search + tool list. + Column(modifier = Modifier.width(300.dp)) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(Res.string.mcp_tools_search)) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + LazyColumn(modifier = Modifier.fillMaxHeight()) { + items(filtered, key = { it.name }) { tool -> + val isSelected = tool.name == selected?.name + val isRunning = tool.name == runningToolName + val callCount = callCounts[tool.name] ?: 0 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + ) + .clickable { onSelectTool(tool.name) } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text( + text = tool.name.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + // Takes the free space so the count and dot sit against the right edge. + modifier = Modifier.weight(1f), + ) + if (callCount > 0 || isRunning) { + McpToolCallCountBadge(count = callCount, running = isRunning) + } + } + } + } + } + VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + // Right pane: the selected tool's detail. + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (selected == null) { + Text( + text = stringResource(Res.string.mcp_tools_no_match), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = selected.name.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = selected.name, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = selected.description, + style = MaterialTheme.typography.bodyMedium, + ) + if (selected.parameters.isNotEmpty()) { + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + selected.parameters.forEach { param -> McpParameterRow(param) } + } + } + } + } +} + +/** What an agent already did with this plugin, newest call first. */ +@Composable +private fun McpCallHistoryPane( + callHistory: ImmutableList, + modifier: Modifier, +) { + if (callHistory.isEmpty()) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.mcp_history_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + // Selection lives above the lazy list so it survives the row scrolling out of view. + var selectedCallId by remember { mutableStateOf(callHistory.first().id) } + val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first() + + Row(modifier = modifier) { + LazyColumn( + modifier = Modifier.width(300.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(callHistory, key = { it.id }) { record -> + McpCallHistoryRow( + record = record, + selected = record.id == selected.id, + onSelect = { selectedCallId = record.id }, + ) + } + } + VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + McpCallDetailPane( + record = selected, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } +} + +/** Right pane: everything recorded about the selected call, with copy actions. */ +@Composable +private fun McpCallDetailPane( + record: McpCallRecord, + modifier: Modifier, +) { + val statusLabel = stringResource( + if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, + ) + val finishedAt = formatCallTime(record.finishedAtEpochMillis) + val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = record.toolName, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = null, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.labelMedium, + color = if (record.succeeded) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + Text( + text = finishedAt, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + if (record.arguments.isEmpty()) { + Text( + text = stringResource(Res.string.mcp_history_no_arguments), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + record.arguments.forEach { argument -> + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = argument.name, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = argument.value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.size(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) }, + ) { + Text(stringResource(Res.string.mcp_history_copy_tool_name)) + } + if (record.arguments.isNotEmpty()) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) }, + ) { + Text(stringResource(Res.string.mcp_history_copy_arguments)) + } + } + TextButton( + onClick = { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = statusLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + ), + ), + ) + }, + ) { + Text(stringResource(Res.string.mcp_history_copy_details)) + } + } + } +} + +@Composable +private fun McpCallHistoryRow( + record: McpCallRecord, + selected: Boolean, + onSelect: () -> Unit, +) { + val statusLabel = stringResource( + if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, + ) + val finishedAt = formatCallTime(record.finishedAtEpochMillis) + val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } + + val clipboardManager = LocalClipboardManager.current + val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name) + val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments) + val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details) + + ContextMenuArea( + items = { + buildList { + add( + ContextMenuItem(copyToolNameLabel) { + clipboardManager.setText(AnnotatedString(record.toolName)) + }, + ) + if (record.arguments.isNotEmpty()) { + add( + ContextMenuItem(copyArgumentsLabel) { + clipboardManager.setText(AnnotatedString(renderedArguments)) + }, + ) + } + add( + ContextMenuItem(copyDetailsLabel) { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = statusLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + ), + ), + ) + }, + ) + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .clickable(role = Role.Button, onClick = onSelect) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + contentDescription = statusLabel, + tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + Text( + text = finishedAt, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} +private fun buildCallDetails( + toolName: String, + statusLabel: String, + finishedAt: String, + renderedArguments: String, +): String = buildString { + appendLine(toolName) + appendLine(statusLabel) + append(finishedAt) + if (renderedArguments.isNotEmpty()) { + appendLine() + append(renderedArguments) + } +} + +/** Wall-clock time of day, which is what the user can line up against their own actions. */ +private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f + +private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + +private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis) + .atZone(ZoneId.systemDefault()) + .format(CallHistoryTimeFormatter) + +@Composable +private fun McpParameterRow(param: McpToolParameterSummary) { + Column( + modifier = Modifier.padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = param.name, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + if (param.type.isNotEmpty()) { + Text( + text = param.type, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (param.required) { + Text( + text = stringResource(Res.string.mcp_tools_required), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + if (param.description.isNotEmpty()) { + Text( + text = param.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index 095f6608..6c198165 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -1,50 +1,25 @@ package com.kitakkun.jetwhale.host.drawer -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.ContextMenuArea -import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,45 +29,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.kitakkun.jetwhale.host.Res -import com.kitakkun.jetwhale.host.close -import com.kitakkun.jetwhale.host.mcp_history_copy_arguments -import com.kitakkun.jetwhale.host.mcp_history_copy_details -import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name -import com.kitakkun.jetwhale.host.mcp_history_empty -import com.kitakkun.jetwhale.host.mcp_history_failed -import com.kitakkun.jetwhale.host.mcp_history_no_arguments -import com.kitakkun.jetwhale.host.mcp_history_succeeded -import com.kitakkun.jetwhale.host.mcp_tool_executing -import com.kitakkun.jetwhale.host.mcp_tools_available -import com.kitakkun.jetwhale.host.mcp_tools_no_match -import com.kitakkun.jetwhale.host.mcp_tools_parameters -import com.kitakkun.jetwhale.host.mcp_tools_required -import com.kitakkun.jetwhale.host.mcp_tools_search -import com.kitakkun.jetwhale.host.mcp_tools_tab_history -import com.kitakkun.jetwhale.host.mcp_tools_tab_tools import com.kitakkun.jetwhale.host.model.McpCallRecord -import com.kitakkun.jetwhale.host.model.McpToolParameterSummary import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginIconResource import com.kitakkun.jetwhale.host.puzzle_filled import com.kitakkun.jetwhale.host.puzzle_outlined import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun PluginDrawerItemView( @@ -242,581 +189,3 @@ private fun McpBadge( ) } } - -/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */ -private enum class McpDialogTab { - Tools, - History, -} - -@Composable -private fun McpToolsDialog( - operating: Boolean, - pluginName: String, - tools: ImmutableList, - callHistory: ImmutableList, - runningToolName: String?, - onDismiss: () -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - // The platform default constrains the dialog to its content's size; tool descriptions and - // history need the room to grow with the window instead. - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface(shape = MaterialTheme.shapes.large) { - Column( - modifier = Modifier - // Take most of the window so long descriptions and history are readable, but - // stop growing past a comfortable reading width on a large display. - .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION) - .sizeIn( - minWidth = 640.dp, - minHeight = 440.dp, - maxWidth = 1200.dp, - maxHeight = 860.dp, - ) - .padding(20.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = pluginName, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), - ) - Text( - text = stringResource( - if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.size(12.dp)) - - var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) } - TabRow(selectedTabIndex = selectedTab.ordinal) { - Tab( - selected = selectedTab == McpDialogTab.Tools, - onClick = { selectedTab = McpDialogTab.Tools }, - text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, - ) - Tab( - selected = selectedTab == McpDialogTab.History, - onClick = { selectedTab = McpDialogTab.History }, - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text(stringResource(Res.string.mcp_tools_tab_history)) - if (callHistory.isNotEmpty()) { - // How many calls the history holds, so the count is visible - // without opening the tab. - McpToolCallCountBadge(count = callHistory.size, running = false) - } - } - }, - ) - } - Spacer(Modifier.size(12.dp)) - - // Hoisted out of the pane so switching tabs and coming back keeps the search and the - // selected tool where the user left them. - var query by remember { mutableStateOf("") } - var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) } - - when (selectedTab) { - McpDialogTab.Tools -> McpToolsPane( - tools = tools, - query = query, - onQueryChange = { query = it }, - selectedToolName = selectedName, - onSelectTool = { selectedName = it }, - runningToolName = runningToolName, - // Counts come from the retained history, so they cover the calls the - // dialog can actually show rather than all time. - callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() }, - modifier = Modifier.weight(1f), - ) - - McpDialogTab.History -> McpCallHistoryPane( - callHistory = callHistory, - modifier = Modifier.weight(1f), - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.close)) - } - } - } - } - } -} - -/** - * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is - * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so - * "being called right now" reads the same way everywhere. - */ -@Composable -private fun McpToolCallCountBadge(count: Int, running: Boolean) { - val shape = RoundedCornerShape(6.dp) - Box( - modifier = Modifier - .clip(shape) - .background( - if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest, - shape, - ) - .then( - if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier, - ) - .padding(horizontal = 7.dp, vertical = 2.dp), - ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelMedium, - fontFamily = FontFamily.Monospace, - color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface, - ) - } -} - -/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */ -@Composable -private fun McpToolsPane( - tools: ImmutableList, - query: String, - onQueryChange: (String) -> Unit, - selectedToolName: String?, - onSelectTool: (String) -> Unit, - runningToolName: String?, - callCounts: Map, - modifier: Modifier, -) { - val filtered = remember(query, tools) { - if (query.isBlank()) { - tools - } else { - tools.filter { - it.name.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - } - } - } - val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull() - - Row(modifier = modifier) { - // Left pane: search + tool list. - Column(modifier = Modifier.width(300.dp)) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - placeholder = { Text(stringResource(Res.string.mcp_tools_search)) }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.size(8.dp)) - LazyColumn(modifier = Modifier.fillMaxHeight()) { - items(filtered, key = { it.name }) { tool -> - val isSelected = tool.name == selected?.name - val isRunning = tool.name == runningToolName - val callCount = callCounts[tool.name] ?: 0 - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, - ) - .clickable { onSelectTool(tool.name) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { - Text( - text = tool.name.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - // Takes the free space so the count and dot sit against the right edge. - modifier = Modifier.weight(1f), - ) - if (callCount > 0 || isRunning) { - McpToolCallCountBadge(count = callCount, running = isRunning) - } - } - } - } - } - VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) - // Right pane: the selected tool's detail. - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (selected == null) { - Text( - text = stringResource(Res.string.mcp_tools_no_match), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = selected.name.substringAfterLast('.'), - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - ) - Text( - text = selected.name, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = selected.description, - style = MaterialTheme.typography.bodyMedium, - ) - if (selected.parameters.isNotEmpty()) { - Spacer(Modifier.size(4.dp)) - Text( - text = stringResource(Res.string.mcp_tools_parameters), - style = MaterialTheme.typography.labelLarge, - ) - selected.parameters.forEach { param -> McpParameterRow(param) } - } - } - } - } -} - -/** What an agent already did with this plugin, newest call first. */ -@Composable -private fun McpCallHistoryPane( - callHistory: ImmutableList, - modifier: Modifier, -) { - if (callHistory.isEmpty()) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.mcp_history_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - return - } - // Selection lives above the lazy list so it survives the row scrolling out of view. - var selectedCallId by remember { mutableStateOf(callHistory.first().id) } - val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first() - - Row(modifier = modifier) { - LazyColumn( - modifier = Modifier.width(300.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - items(callHistory, key = { it.id }) { record -> - McpCallHistoryRow( - record = record, - selected = record.id == selected.id, - onSelect = { selectedCallId = record.id }, - ) - } - } - VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp)) - McpCallDetailPane( - record = selected, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } -} - -/** Right pane: everything recorded about the selected call, with copy actions. */ -@Composable -private fun McpCallDetailPane( - record: McpCallRecord, - modifier: Modifier, -) { - val statusLabel = stringResource( - if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, - ) - val finishedAt = formatCallTime(record.finishedAtEpochMillis) - val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } - val clipboardManager = LocalClipboardManager.current - - Column( - modifier = modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = record.toolName.substringAfterLast('.'), - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - ) - Text( - text = record.toolName, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, - contentDescription = null, - tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp), - ) - Text( - text = statusLabel, - style = MaterialTheme.typography.labelMedium, - color = if (record.succeeded) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.error - }, - ) - Text( - text = finishedAt, - style = MaterialTheme.typography.labelMedium, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(Modifier.size(4.dp)) - Text( - text = stringResource(Res.string.mcp_tools_parameters), - style = MaterialTheme.typography.labelLarge, - ) - if (record.arguments.isEmpty()) { - Text( - text = stringResource(Res.string.mcp_history_no_arguments), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - record.arguments.forEach { argument -> - Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { - Text( - text = argument.name, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - ) - Text( - text = argument.value, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - Spacer(Modifier.size(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) }, - ) { - Text(stringResource(Res.string.mcp_history_copy_tool_name)) - } - if (record.arguments.isNotEmpty()) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) }, - ) { - Text(stringResource(Res.string.mcp_history_copy_arguments)) - } - } - TextButton( - onClick = { - clipboardManager.setText( - AnnotatedString( - buildCallDetails( - toolName = record.toolName, - statusLabel = statusLabel, - finishedAt = finishedAt, - renderedArguments = renderedArguments, - ), - ), - ) - }, - ) { - Text(stringResource(Res.string.mcp_history_copy_details)) - } - } - } -} - -@Composable -private fun McpCallHistoryRow( - record: McpCallRecord, - selected: Boolean, - onSelect: () -> Unit, -) { - val statusLabel = stringResource( - if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed, - ) - val finishedAt = formatCallTime(record.finishedAtEpochMillis) - val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" } - - val clipboardManager = LocalClipboardManager.current - val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name) - val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments) - val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details) - - ContextMenuArea( - items = { - buildList { - add( - ContextMenuItem(copyToolNameLabel) { - clipboardManager.setText(AnnotatedString(record.toolName)) - }, - ) - if (record.arguments.isNotEmpty()) { - add( - ContextMenuItem(copyArgumentsLabel) { - clipboardManager.setText(AnnotatedString(renderedArguments)) - }, - ) - } - add( - ContextMenuItem(copyDetailsLabel) { - clipboardManager.setText( - AnnotatedString( - buildCallDetails( - toolName = record.toolName, - statusLabel = statusLabel, - finishedAt = finishedAt, - renderedArguments = renderedArguments, - ), - ), - ) - }, - ) - } - }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) - .clickable(role = Role.Button, onClick = onSelect) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, - contentDescription = statusLabel, - tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp), - ) - Text( - text = record.toolName.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.weight(1f), - ) - Text( - text = finishedAt, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} -private fun buildCallDetails( - toolName: String, - statusLabel: String, - finishedAt: String, - renderedArguments: String, -): String = buildString { - appendLine(toolName) - appendLine(statusLabel) - append(finishedAt) - if (renderedArguments.isNotEmpty()) { - appendLine() - append(renderedArguments) - } -} - -/** Wall-clock time of day, which is what the user can line up against their own actions. */ -private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f - -private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") - -private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis) - .atZone(ZoneId.systemDefault()) - .format(CallHistoryTimeFormatter) - -@Composable -private fun McpParameterRow(param: McpToolParameterSummary) { - Column( - modifier = Modifier.padding(top = 6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = param.name, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - ) - if (param.type.isNotEmpty()) { - Text( - text = param.type, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (param.required) { - Text( - text = stringResource(Res.string.mcp_tools_required), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - if (param.description.isNotEmpty()) { - Text( - text = param.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} From aae35376d01b9cce9603dc975b2065696b61d192 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:28:22 +0900 Subject: [PATCH 07/13] feat(host): record and reveal MCP call responses Call history showed what an agent asked for but not what it got back. The tool registrar now renders every CallToolResult to text -- text blocks verbatim, binary blocks (image, audio, resource) named rather than inlined so history does not fill with base64 -- and hands it to the repository along with the failure flag. A throwing handler records its message instead, so a failed call is still explainable. Responses are truncated at record time like arguments, but against their own McpCallRecord.MAX_RESPONSE_LENGTH: the response is the payload the user opens history to read, whereas an argument only has to be recognisable. The detail pane gains a Response section rendered in monospace inside a bounded, self-scrolling box, so a long response stays readable without pushing the copy actions out of the pane. Copy response joins the button row and the row context menu, and Copy details carries the response too. --- .../composeResources/values-ja/strings.xml | 3 + .../main/composeResources/values/strings.xml | 3 + .../jetwhale/host/drawer/McpToolsDialog.kt | 61 +++++++++++++++++ .../server/DefaultMcpActivityRepository.kt | 4 +- .../DefaultMcpActivityRepositoryTest.kt | 68 ++++++++++++++++--- .../jetwhale/host/mcp/McpToolRegistrar.kt | 27 +++++++- .../host/mcp/DefaultMcpServerServiceTest.kt | 42 ++++++++++++ .../host/mcp/FakeMcpActivityRepository.kt | 4 +- .../jetwhale/host/model/McpActivity.kt | 26 ++++++- .../host/model/McpActivityRepository.kt | 5 +- 10 files changed, 227 insertions(+), 16 deletions(-) diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index 8feac7ca..aa769822 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -46,5 +46,8 @@ ツール名をコピー 引数をコピー 詳細をコピー + レスポンスをコピー 引数なし + レスポンス + レスポンスなし diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index 0e641462..f1c2bf4e 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -46,5 +46,8 @@ Copy tool name Copy arguments Copy details + Copy response No arguments + Response + No response diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt index 03845d23..a2f58089 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn @@ -55,10 +56,13 @@ import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.close import com.kitakkun.jetwhale.host.mcp_history_copy_arguments import com.kitakkun.jetwhale.host.mcp_history_copy_details +import com.kitakkun.jetwhale.host.mcp_history_copy_response import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name import com.kitakkun.jetwhale.host.mcp_history_empty import com.kitakkun.jetwhale.host.mcp_history_failed import com.kitakkun.jetwhale.host.mcp_history_no_arguments +import com.kitakkun.jetwhale.host.mcp_history_no_response +import com.kitakkun.jetwhale.host.mcp_history_response import com.kitakkun.jetwhale.host.mcp_history_succeeded import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available @@ -469,6 +473,38 @@ private fun McpCallDetailPane( } } + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_history_response), + style = MaterialTheme.typography.labelLarge, + ) + if (record.response.isEmpty()) { + Text( + text = stringResource(Res.string.mcp_history_no_response), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + // Bounded and scrolled on its own so a long response stays readable instead of + // pushing the copy actions out of the pane. + .heightIn(max = 240.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .verticalScroll(rememberScrollState()) + .padding(8.dp), + ) { + Text( + text = record.response, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.size(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TextButton( @@ -483,6 +519,13 @@ private fun McpCallDetailPane( Text(stringResource(Res.string.mcp_history_copy_arguments)) } } + if (record.response.isNotEmpty()) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(record.response)) }, + ) { + Text(stringResource(Res.string.mcp_history_copy_response)) + } + } TextButton( onClick = { clipboardManager.setText( @@ -492,6 +535,7 @@ private fun McpCallDetailPane( statusLabel = statusLabel, finishedAt = finishedAt, renderedArguments = renderedArguments, + response = record.response, ), ), ) @@ -518,6 +562,7 @@ private fun McpCallHistoryRow( val clipboardManager = LocalClipboardManager.current val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name) val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments) + val copyResponseLabel = stringResource(Res.string.mcp_history_copy_response) val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details) ContextMenuArea( @@ -535,6 +580,13 @@ private fun McpCallHistoryRow( }, ) } + if (record.response.isNotEmpty()) { + add( + ContextMenuItem(copyResponseLabel) { + clipboardManager.setText(AnnotatedString(record.response)) + }, + ) + } add( ContextMenuItem(copyDetailsLabel) { clipboardManager.setText( @@ -544,6 +596,7 @@ private fun McpCallHistoryRow( statusLabel = statusLabel, finishedAt = finishedAt, renderedArguments = renderedArguments, + response = record.response, ), ), ) @@ -595,6 +648,7 @@ private fun buildCallDetails( statusLabel: String, finishedAt: String, renderedArguments: String, + response: String, ): String = buildString { appendLine(toolName) appendLine(statusLabel) @@ -603,6 +657,13 @@ private fun buildCallDetails( appendLine() append(renderedArguments) } + if (response.isNotEmpty()) { + // A blank line keeps the response apart from the arguments above it, which are otherwise + // laid out the same way. + appendLine() + appendLine() + append(response) + } } /** Wall-clock time of day, which is what the user can line up against their own actions. */ diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt index 1da37880..d7ec4c4a 100644 --- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt +++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt @@ -61,9 +61,10 @@ class DefaultMcpActivityRepository : McpActivityRepository { return invocationId } - override fun toolInvocationFinished(invocationId: Long, failed: Boolean) { + override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) { // Sampled once outside the update block, which may re-run under contention. val finishedAtEpochMillis = System.currentTimeMillis() + val truncatedResponse = McpCallRecord.truncateResponse(response) activityFlow.update { activity -> val finished = activity.runningInvocations.firstOrNull { it.id == invocationId } activity.copy( @@ -82,6 +83,7 @@ class DefaultMcpActivityRepository : McpActivityRepository { succeeded = !failed, finishedAtEpochMillis = finishedAtEpochMillis, arguments = finished.arguments, + response = truncatedResponse, ) (listOf(record) + activity.recentCalls) .take(McpActivity.MAX_RECENT_CALLS) diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt index 662026f4..2ea14ebb 100644 --- a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt +++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt @@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpCallArgument +import com.kitakkun.jetwhale.host.model.McpCallRecord import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -19,7 +20,7 @@ class DefaultMcpActivityRepositoryTest { @Test fun `a completed call is recorded with its attribution`() { val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") val record = repository.activityFlow.value.recentCalls.single() assertEquals("plugin.click", record.toolName) @@ -38,7 +39,7 @@ class DefaultMcpActivityRepositoryTest { @Test fun `a failed call is recorded as unsuccessful`() { val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = true) + repository.toolInvocationFinished(id, failed = true, response = "") assertFalse(repository.activityFlow.value.recentCalls.single().succeeded) } @@ -47,7 +48,7 @@ class DefaultMcpActivityRepositoryTest { fun `history is ordered newest first`() { listOf("first", "second", "third").forEach { toolName -> val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") } assertEquals( @@ -61,7 +62,7 @@ class DefaultMcpActivityRepositoryTest { val overflow = McpActivity.MAX_RECENT_CALLS + 5 repeat(overflow) { index -> val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") } val recentCalls = repository.activityFlow.value.recentCalls @@ -72,7 +73,7 @@ class DefaultMcpActivityRepositoryTest { @Test fun `finishing an unknown invocation records nothing`() { - repository.toolInvocationFinished(invocationId = 404L, failed = false) + repository.toolInvocationFinished(invocationId = 404L, failed = false, response = "") assertTrue(repository.activityFlow.value.recentCalls.isEmpty()) } @@ -85,7 +86,7 @@ class DefaultMcpActivityRepositoryTest { "session-1", mapOf("sessionId" to "session-1", "x" to "100"), ) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") val record = repository.activityFlow.value.recentCalls.single() assertEquals( @@ -106,7 +107,7 @@ class DefaultMcpActivityRepositoryTest { "session-1", mapOf("text" to value), ) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") val recorded = repository.activityFlow.value.recentCalls.single().arguments.single() assertEquals( @@ -124,7 +125,7 @@ class DefaultMcpActivityRepositoryTest { "session-1", mapOf("text" to value), ) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") assertEquals(value, repository.activityFlow.value.recentCalls.single().arguments.single().value) } @@ -132,15 +133,62 @@ class DefaultMcpActivityRepositoryTest { @Test fun `a call without arguments records an empty argument list`() { val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") assertTrue(repository.activityFlow.value.recentCalls.single().arguments.isEmpty()) } + @Test + fun `a completed call is recorded with the response it produced`() { + val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = "") + + assertEquals("", repository.activityFlow.value.recentCalls.single().response) + } + + @Test + fun `a long response is truncated with an ellipsis`() { + val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH + 20) + val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = response) + + assertEquals( + "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER, + repository.activityFlow.value.recentCalls.single().response, + ) + } + + @Test + fun `a response at the cap is kept whole`() { + val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH) + val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = response) + + assertEquals(response, repository.activityFlow.value.recentCalls.single().response) + } + + @Test + fun `a call without a response records an empty response`() { + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = "") + + assertEquals("", repository.activityFlow.value.recentCalls.single().response) + } + + @Test + fun `a failed call is recorded with the failure message as its response`() { + val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) + repository.toolInvocationFinished(id, failed = true, response = "boom") + + val record = repository.activityFlow.value.recentCalls.single() + assertFalse(record.succeeded) + assertEquals("boom", record.response) + } + @Test fun `clear drops the recorded history`() { val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap()) - repository.toolInvocationFinished(id, failed = false) + repository.toolInvocationFinished(id, failed = false, response = "") repository.clear() diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index 6073f3e0..bb3bfe3f 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema /** @@ -89,11 +90,33 @@ class McpToolRegistrar( // A thrown handler is the only failure signal available here, and it has to reach the // repository before it propagates on to the MCP layer. var failed = true + var response = "" try { - handler(request).also { failed = false } + handler(request).also { + failed = false + response = it.renderForHistory() + } + } catch (throwable: Throwable) { + // A failure is only explainable in history if it says what went wrong. + response = throwable.message.orEmpty() + throw throwable } finally { - activityRepository.toolInvocationFinished(invocationId, failed) + activityRepository.toolInvocationFinished(invocationId, failed, response) } } } } + +/** + * Renders a tool result to the text kept in call history. + * + * Only text blocks carry something a reader can use; every other block type is a binary payload (a + * screenshot, audio, an embedded resource) that is named rather than inlined, so history does not + * fill up with base64. + */ +private fun CallToolResult.renderForHistory(): String = content.joinToString(separator = "\n") { block -> + when (block) { + is TextContent -> block.text + else -> "<${block.type.value}>" + } +} diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index 6028d16f..0d30b96e 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -19,6 +19,7 @@ import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.sse.SSE import io.modelcontextprotocol.kotlin.sdk.client.mcpSse import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.ImageContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.TimeoutCancellationException @@ -440,6 +441,32 @@ class DefaultMcpServerServiceTest { assertEquals("com.example.plugin", record.pluginId) assertEquals("session-1", record.sessionId) assertTrue(record.succeeded) + assertEquals("ok", record.response) + } + + @Test + fun `a non-text response block is recorded as a placeholder instead of its payload`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(MediaMcpTool("fake.captured")), + ) + val capturedPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, capturedPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$capturedPort/sse") + try { + client.callTool("fake.captured", emptyMap()) + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertEquals("captured\n", record.response) } @Test @@ -466,6 +493,7 @@ class DefaultMcpServerServiceTest { assertEquals("fake.failing", record.toolName) assertFalse(record.succeeded) + assertEquals("boom", record.response) } @OptIn(ExperimentalJetWhaleApi::class) @@ -542,6 +570,20 @@ private class FakeMcpTool( } } +/** Returns a text block alongside a binary one, which history must name rather than inline. */ +private class MediaMcpTool(private val name: String) : JetWhaleMcpTool { + override fun register(registrar: McpToolRegistrar) { + registrar.addTool(name = name, description = "Returns text and an image", inputSchema = ToolSchema()) { _ -> + CallToolResult( + content = listOf( + TextContent("captured"), + ImageContent(data = "AAAA", mimeType = "image/png"), + ), + ) + } + } +} + private class FailingMcpTool(private val name: String) : JetWhaleMcpTool { override fun register(registrar: McpToolRegistrar) { registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ -> diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt index f2192082..d1a92ddb 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt @@ -61,8 +61,9 @@ class FakeMcpActivityRepository : McpActivityRepository { return invocation.id } - override fun toolInvocationFinished(invocationId: Long, failed: Boolean) { + override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) { val finishedAtEpochMillis = System.currentTimeMillis() + val truncatedResponse = McpCallRecord.truncateResponse(response) activityFlow.update { activity -> val finished = activity.runningInvocations.firstOrNull { it.id == invocationId } activity.copy( @@ -80,6 +81,7 @@ class FakeMcpActivityRepository : McpActivityRepository { succeeded = !failed, finishedAtEpochMillis = finishedAtEpochMillis, arguments = finished.arguments, + response = truncatedResponse, ) (listOf(record) + activity.recentCalls) .take(McpActivity.MAX_RECENT_CALLS) diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt index 8aa86327..9a5f7b2c 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt @@ -63,7 +63,31 @@ data class McpCallRecord( val finishedAtEpochMillis: Long, /** Every argument the call was made with, including `pluginId` and `sessionId`. */ val arguments: ImmutableList, -) + /** + * What the call produced, rendered to a string and shortened to [MAX_RESPONSE_LENGTH]. Carries + * the failure message when the call failed, and is empty when there is nothing to show. + */ + val response: String, +) { + companion object { + /** + * How many characters of a [response] are kept. Far more generous than + * [McpCallArgument.MAX_VALUE_LENGTH]: the response is the payload the user opens history to + * read, whereas an argument only has to be recognisable. + */ + const val MAX_RESPONSE_LENGTH = 2000 + + /** + * Shortens a response to [MAX_RESPONSE_LENGTH] characters, marking the cut the same way + * argument values are marked. + */ + fun truncateResponse(response: String): String = if (response.length <= MAX_RESPONSE_LENGTH) { + response + } else { + response.take(MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER + } + } +} /** * Live view of what AI agents are doing through the MCP server, so the UI can tell the user when diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt index a0eae452..fc77fab2 100644 --- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt +++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt @@ -35,8 +35,11 @@ interface McpActivityRepository { * Records the completion of a tool call and appends it to the recent-call history. * * @param failed true when the handler threw instead of returning a result. + * @param response what the call produced, already rendered to a string: the tool's result, or + * the failure message when it threw. Shortened to [McpCallRecord.MAX_RESPONSE_LENGTH] here. Pass + * an empty string when the call produced nothing to show. */ - fun toolInvocationFinished(invocationId: Long, failed: Boolean) + fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) /** Drops all recorded activity, so a server restart does not inherit stale connection counts. */ fun clear() From debd75b1f847c12e3c5a2cc0e8ba48358f02b4db Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:32:12 +0900 Subject: [PATCH 08/13] feat(host): add the nav key and screen context for an MCP tools screen Groundwork for opening the MCP browser as a screen rather than a dialog scoped to one plugin: a nav key whose null plugin/session mean "all", and a screen context that subscribes to the tools, activity, sessions, and plugin metadata itself. --- .../jetwhale/host/di/JetWhaleAppGraph.kt | 2 ++ .../host/drawer/McpToolsScreenContext.kt | 20 +++++++++++++++++++ .../host/navigation/JetWhaleNavKeys.kt | 11 ++++++++++ 3 files changed, 33 insertions(+) create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt index 6f1abd96..773e39f0 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.host.di import com.kitakkun.jetwhale.host.ApplicationLifecycleOwner import com.kitakkun.jetwhale.host.BuildConfig import com.kitakkun.jetwhale.host.architecture.ScreenContext +import com.kitakkun.jetwhale.host.drawer.McpToolsScreenContext import com.kitakkun.jetwhale.host.drawer.ToolingScaffoldScreenContext import com.kitakkun.jetwhale.host.mcp.McpServerService import com.kitakkun.jetwhale.host.model.AppearanceSettingsSubscriptionKey @@ -38,6 +39,7 @@ interface JetWhaleAppGraph : ScreenContext { val swrClient: SwrClientPlus val toolingScaffoldScreenContext: ToolingScaffoldScreenContext + val mcpToolsScreenContext: McpToolsScreenContext val licensesScreenContext: LicensesScreenContext val settingsScreenContext: SettingsScreenContext val pluginScreenContextFactory: PluginScreenContext.Factory diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt new file mode 100644 index 00000000..95828ba7 --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt @@ -0,0 +1,20 @@ +package com.kitakkun.jetwhale.host.drawer + +import com.kitakkun.jetwhale.host.architecture.ScreenContext +import com.kitakkun.jetwhale.host.model.DebugSessionsSubscriptionKey +import com.kitakkun.jetwhale.host.model.LoadedPluginsMetaDataSubscriptionKey +import com.kitakkun.jetwhale.host.model.McpActivitySubscriptionKey +import com.kitakkun.jetwhale.host.model.McpCapablePluginsSubscriptionKey +import dev.zacsweers.metro.Inject + +/** + * The MCP tools browser subscribes to the whole picture itself rather than being handed one + * plugin's slice, so it can show every session and plugin and let the user narrow it down. + */ +@Inject +class McpToolsScreenContext( + val mcpCapablePluginsSubscriptionKey: McpCapablePluginsSubscriptionKey, + val mcpActivitySubscriptionKey: McpActivitySubscriptionKey, + val debugSessionsSubscriptionKey: DebugSessionsSubscriptionKey, + val loadedPluginsMetaDataSubscriptionKey: LoadedPluginsMetaDataSubscriptionKey, +) : ScreenContext diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt index 272d34c2..38ab0995 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt @@ -36,3 +36,14 @@ data object DisabledPluginNavKey : NavKey @Serializable data object LogViewerNavKey : NavKey + +/** + * The MCP tools browser. [pluginId] and [sessionId] seed the screen's filters — null means + * "all", so opening it from a plugin's badge lands on that plugin while the screen itself can + * widen the view afterwards. + */ +@Serializable +data class McpToolsNavKey( + val pluginId: String?, + val sessionId: String?, +) : NavKey From 8580795fe39fdc4ed39935b10045277fddea054e Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:08:14 +0900 Subject: [PATCH 09/13] feat(host): browse MCP tools in a screen with plugin and session filters The MCP browser was a dialog handed one plugin's slice of tools and history, so it could not answer "when was this tool last called?" across plugins. Make it a window-backed screen that subscribes to the MCP, session and plugin data itself through `McpToolsScreenContext`, and narrow it with two filters seeded from `McpToolsNavKey`: a null plugin or session id means "All", and both can be changed once the screen is open. Tool rows now name the plugin that publishes them, since the short name alone is ambiguous once plugins are mixed. History keeps calls that named no session visible under a specific session too, because those tools target none. The per-section copy actions move inline next to the heading they copy, leaving "Copy details" as the pane's only labelled button. --- .../composeResources/values-ja/strings.xml | 5 + .../main/composeResources/values/strings.xml | 5 + .../{McpToolsDialog.kt => McpToolsScreen.kt} | 477 ++++++++++-------- .../host/drawer/McpToolsScreenRoot.kt | 160 ++++++ .../host/navigation/JetWhaleNavDisplay.kt | 1 + .../host/navigation/NavBackStackExtensions.kt | 14 + .../jetwhale/host/navigation/NavEntries.kt | 32 ++ 7 files changed, 496 insertions(+), 198 deletions(-) rename jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/{McpToolsDialog.kt => McpToolsScreen.kt} (63%) create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index aa769822..699f4b4d 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -50,4 +50,9 @@ 引数なし レスポンス レスポンスなし + MCP Tools + MCP tools を閲覧 + プラグイン + セッション + すべて diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index f1c2bf4e..7a77507d 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -50,4 +50,9 @@ No arguments Response No response + MCP Tools + Browse MCP tools + Plugin + Session + All diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt similarity index 63% rename from jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt rename to jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt index a2f58089..03a2b8d4 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt @@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -24,12 +24,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -50,10 +54,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.kitakkun.jetwhale.host.Res -import com.kitakkun.jetwhale.host.close import com.kitakkun.jetwhale.host.mcp_history_copy_arguments import com.kitakkun.jetwhale.host.mcp_history_copy_details import com.kitakkun.jetwhale.host.mcp_history_copy_response @@ -66,6 +67,9 @@ import com.kitakkun.jetwhale.host.mcp_history_response import com.kitakkun.jetwhale.host.mcp_history_succeeded import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available +import com.kitakkun.jetwhale.host.mcp_tools_filter_all +import com.kitakkun.jetwhale.host.mcp_tools_filter_plugin +import com.kitakkun.jetwhale.host.mcp_tools_filter_session import com.kitakkun.jetwhale.host.mcp_tools_no_match import com.kitakkun.jetwhale.host.mcp_tools_parameters import com.kitakkun.jetwhale.host.mcp_tools_required @@ -74,124 +78,158 @@ import com.kitakkun.jetwhale.host.mcp_tools_tab_history import com.kitakkun.jetwhale.host.mcp_tools_tab_tools import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolParameterSummary -import com.kitakkun.jetwhale.host.model.McpToolSummary import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.stringResource import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */ -internal enum class McpDialogTab { +/** Everything the MCP tools browser draws, already narrowed to the selected plugin and session. */ +data class McpToolsScreenUiState( + val pluginOptions: ImmutableList, + val sessionOptions: ImmutableList, + val selectedPluginId: String?, + val selectedSessionId: String?, + val toolRows: ImmutableList, + val callHistory: ImmutableList, + val runningToolName: String?, +) + +/** The panes the MCP browser can show: the tools plugins publish, or the calls already made. */ +internal enum class McpToolsTab { Tools, History, } @Composable -internal fun McpToolsDialog( - operating: Boolean, - pluginName: String, - tools: ImmutableList, - callHistory: ImmutableList, - runningToolName: String?, - onDismiss: () -> Unit, +fun McpToolsScreen( + uiState: McpToolsScreenUiState, + onSelectPluginFilter: (String?) -> Unit, + onSelectSessionFilter: (String?) -> Unit, ) { - Dialog( - onDismissRequest = onDismiss, - // The platform default constrains the dialog to its content's size; tool descriptions and - // history need the room to grow with the window instead. - properties = DialogProperties(usePlatformDefaultWidth = false), + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), ) { - Surface(shape = MaterialTheme.shapes.large) { - Column( - modifier = Modifier - // Take most of the window so long descriptions and history are readable, but - // stop growing past a comfortable reading width on a large display. - .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION) - .sizeIn( - minWidth = 640.dp, - minHeight = 440.dp, - maxWidth = 1200.dp, - maxHeight = 860.dp, - ) - .padding(20.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = pluginName, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), - ) - Text( - text = stringResource( - if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.size(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + McpFilterDropdown( + label = stringResource(Res.string.mcp_tools_filter_plugin), + options = uiState.pluginOptions, + selectedId = uiState.selectedPluginId, + onSelect = onSelectPluginFilter, + ) + McpFilterDropdown( + label = stringResource(Res.string.mcp_tools_filter_session), + options = uiState.sessionOptions, + selectedId = uiState.selectedSessionId, + onSelect = onSelectSessionFilter, + ) + Spacer(Modifier.weight(1f)) + Text( + text = stringResource( + if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.size(12.dp)) - var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) } - TabRow(selectedTabIndex = selectedTab.ordinal) { - Tab( - selected = selectedTab == McpDialogTab.Tools, - onClick = { selectedTab = McpDialogTab.Tools }, - text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, - ) - Tab( - selected = selectedTab == McpDialogTab.History, - onClick = { selectedTab = McpDialogTab.History }, - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text(stringResource(Res.string.mcp_tools_tab_history)) - if (callHistory.isNotEmpty()) { - // How many calls the history holds, so the count is visible - // without opening the tab. - McpToolCallCountBadge(count = callHistory.size, running = false) - } - } - }, - ) - } - Spacer(Modifier.size(12.dp)) + var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) } + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == McpToolsTab.Tools, + onClick = { selectedTab = McpToolsTab.Tools }, + text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, + ) + Tab( + selected = selectedTab == McpToolsTab.History, + onClick = { selectedTab = McpToolsTab.History }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(stringResource(Res.string.mcp_tools_tab_history)) + if (uiState.callHistory.isNotEmpty()) { + // How many calls the current scope holds, so the count is visible + // without opening the tab. + McpToolCallCountBadge(count = uiState.callHistory.size, running = false) + } + } + }, + ) + } + Spacer(Modifier.size(12.dp)) - // Hoisted out of the pane so switching tabs and coming back keeps the search and the - // selected tool where the user left them. - var query by remember { mutableStateOf("") } - var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) } + // Hoisted out of the pane so switching tabs and coming back keeps the search and the + // selected tool where the user left them. + var query by remember { mutableStateOf("") } + var selectedToolKey by remember { mutableStateOf(null) } - when (selectedTab) { - McpDialogTab.Tools -> McpToolsPane( - tools = tools, - query = query, - onQueryChange = { query = it }, - selectedToolName = selectedName, - onSelectTool = { selectedName = it }, - runningToolName = runningToolName, - // Counts come from the retained history, so they cover the calls the - // dialog can actually show rather than all time. - callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() }, - modifier = Modifier.weight(1f), - ) + when (selectedTab) { + McpToolsTab.Tools -> McpToolsPane( + toolRows = uiState.toolRows, + query = query, + onQueryChange = { query = it }, + selectedToolKey = selectedToolKey, + onSelectTool = { selectedToolKey = it }, + modifier = Modifier.weight(1f), + ) - McpDialogTab.History -> McpCallHistoryPane( - callHistory = callHistory, - modifier = Modifier.weight(1f), - ) - } + McpToolsTab.History -> McpCallHistoryPane( + callHistory = uiState.callHistory, + modifier = Modifier.weight(1f), + ) + } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.close)) - } - } +/** "All" plus one entry per option, so the user can widen a filter the nav key seeded. */ +@Composable +private fun McpFilterDropdown( + label: String, + options: ImmutableList, + selectedId: String?, + onSelect: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val allLabel = stringResource(Res.string.mcp_tools_filter_all) + val selectedLabel = options.firstOrNull { it.id == selectedId }?.label ?: allLabel + + Box { + OutlinedButton(onClick = { expanded = true }) { + Text( + text = "$label: $selectedLabel", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 260.dp), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(allLabel) }, + onClick = { + onSelect(null) + expanded = false + }, + ) + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + onSelect(option.id) + expanded = false + }, + ) } } } @@ -226,33 +264,32 @@ internal fun McpToolCallCountBadge(count: Int, running: Boolean) { } } -/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */ +/** Two-pane browser over the tools in scope: search + list on the left, detail right. */ @Composable private fun McpToolsPane( - tools: ImmutableList, + toolRows: ImmutableList, query: String, onQueryChange: (String) -> Unit, - selectedToolName: String?, + selectedToolKey: String?, onSelectTool: (String) -> Unit, - runningToolName: String?, - callCounts: Map, modifier: Modifier, ) { - val filtered = remember(query, tools) { + val filtered = remember(query, toolRows) { if (query.isBlank()) { - tools + toolRows } else { - tools.filter { - it.name.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) + toolRows.filter { + it.tool.name.contains(query, ignoreCase = true) || + it.tool.description.contains(query, ignoreCase = true) || + it.pluginName.contains(query, ignoreCase = true) } } } - val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull() + val selected = filtered.firstOrNull { it.key == selectedToolKey } ?: filtered.firstOrNull() Row(modifier = modifier) { // Left pane: search + tool list. - Column(modifier = Modifier.width(300.dp)) { + Column(modifier = Modifier.width(320.dp)) { OutlinedTextField( value = query, onValueChange = onQueryChange, @@ -263,10 +300,8 @@ private fun McpToolsPane( ) Spacer(Modifier.size(8.dp)) LazyColumn(modifier = Modifier.fillMaxHeight()) { - items(filtered, key = { it.name }) { tool -> - val isSelected = tool.name == selected?.name - val isRunning = tool.name == runningToolName - val callCount = callCounts[tool.name] ?: 0 + items(filtered, key = { it.key }) { row -> + val isSelected = row.key == selected?.key Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -276,25 +311,35 @@ private fun McpToolsPane( .background( if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, ) - .clickable { onSelectTool(tool.name) } + .clickable { onSelectTool(row.key) } .padding(horizontal = 10.dp, vertical = 8.dp), ) { - Text( - text = tool.name.substringAfterLast('.'), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - // Takes the free space so the count and dot sit against the right edge. - modifier = Modifier.weight(1f), - ) - if (callCount > 0 || isRunning) { - McpToolCallCountBadge(count = callCount, running = isRunning) + // Takes the free space so the count sits against the right edge. + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.tool.name.substringAfterLast('.'), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + // The short name alone is ambiguous once plugins are mixed, so every row + // names the plugin that publishes the tool. + Text( + text = row.pluginName, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (row.callCount > 0 || row.running) { + McpToolCallCountBadge(count = row.callCount, running = row.running) } } } @@ -316,34 +361,39 @@ private fun McpToolsPane( ) } else { Text( - text = selected.name.substringAfterLast('.'), + text = selected.tool.name.substringAfterLast('.'), style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, ) Text( - text = selected.name, + text = selected.pluginName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = selected.tool.name, style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = selected.description, + text = selected.tool.description, style = MaterialTheme.typography.bodyMedium, ) - if (selected.parameters.isNotEmpty()) { + if (selected.tool.parameters.isNotEmpty()) { Spacer(Modifier.size(4.dp)) Text( text = stringResource(Res.string.mcp_tools_parameters), style = MaterialTheme.typography.labelLarge, ) - selected.parameters.forEach { param -> McpParameterRow(param) } + selected.tool.parameters.forEach { param -> McpParameterRow(param) } } } } } } -/** What an agent already did with this plugin, newest call first. */ +/** What agents already did in the selected scope, newest call first. */ @Composable private fun McpCallHistoryPane( callHistory: ImmutableList, @@ -368,7 +418,7 @@ private fun McpCallHistoryPane( Row(modifier = modifier) { LazyColumn( - modifier = Modifier.width(300.dp), + modifier = Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { items(callHistory, key = { it.id }) { record -> @@ -389,7 +439,10 @@ private fun McpCallHistoryPane( } } -/** Right pane: everything recorded about the selected call, with copy actions. */ +/** + * Right pane: everything recorded about the selected call. Each section carries its own inline copy + * icon; the one action that takes the whole record stays a labelled button. + */ @Composable private fun McpCallDetailPane( record: McpCallRecord, @@ -406,11 +459,20 @@ private fun McpCallDetailPane( modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = record.toolName.substringAfterLast('.'), - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = record.toolName.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + McpCopyIconButton( + contentDescription = stringResource(Res.string.mcp_history_copy_tool_name), + onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) }, + ) + } Text( text = record.toolName, style = MaterialTheme.typography.labelSmall, @@ -445,10 +507,21 @@ private fun McpCallDetailPane( } Spacer(Modifier.size(4.dp)) - Text( - text = stringResource(Res.string.mcp_tools_parameters), - style = MaterialTheme.typography.labelLarge, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + if (record.arguments.isNotEmpty()) { + McpCopyIconButton( + contentDescription = stringResource(Res.string.mcp_history_copy_arguments), + onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) }, + ) + } + } if (record.arguments.isEmpty()) { Text( text = stringResource(Res.string.mcp_history_no_arguments), @@ -474,10 +547,21 @@ private fun McpCallDetailPane( } Spacer(Modifier.size(4.dp)) - Text( - text = stringResource(Res.string.mcp_history_response), - style = MaterialTheme.typography.labelLarge, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(Res.string.mcp_history_response), + style = MaterialTheme.typography.labelLarge, + ) + if (record.response.isNotEmpty()) { + McpCopyIconButton( + contentDescription = stringResource(Res.string.mcp_history_copy_response), + onClick = { clipboardManager.setText(AnnotatedString(record.response)) }, + ) + } + } if (record.response.isEmpty()) { Text( text = stringResource(Res.string.mcp_history_no_response), @@ -489,7 +573,7 @@ private fun McpCallDetailPane( modifier = Modifier .fillMaxWidth() // Bounded and scrolled on its own so a long response stays readable instead of - // pushing the copy actions out of the pane. + // pushing the copy action out of the pane. .heightIn(max = 240.dp) .clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHighest) @@ -506,47 +590,45 @@ private fun McpCallDetailPane( } Spacer(Modifier.size(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) }, - ) { - Text(stringResource(Res.string.mcp_history_copy_tool_name)) - } - if (record.arguments.isNotEmpty()) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) }, - ) { - Text(stringResource(Res.string.mcp_history_copy_arguments)) - } - } - if (record.response.isNotEmpty()) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(record.response)) }, - ) { - Text(stringResource(Res.string.mcp_history_copy_response)) - } - } - TextButton( - onClick = { - clipboardManager.setText( - AnnotatedString( - buildCallDetails( - toolName = record.toolName, - statusLabel = statusLabel, - finishedAt = finishedAt, - renderedArguments = renderedArguments, - response = record.response, - ), + TextButton( + onClick = { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = statusLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + response = record.response, ), - ) - }, - ) { - Text(stringResource(Res.string.mcp_history_copy_details)) - } + ), + ) + }, + ) { + Text(stringResource(Res.string.mcp_history_copy_details)) } } } +/** Copy affordance that sits beside a heading without competing with it for attention. */ +@Composable +private fun McpCopyIconButton( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } +} + @Composable private fun McpCallHistoryRow( record: McpCallRecord, @@ -643,6 +725,7 @@ private fun McpCallHistoryRow( } } } + private fun buildCallDetails( toolName: String, statusLabel: String, @@ -667,8 +750,6 @@ private fun buildCallDetails( } /** Wall-clock time of day, which is what the user can line up against their own actions. */ -private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f - private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis) diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt new file mode 100644 index 00000000..473c1b76 --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt @@ -0,0 +1,160 @@ +package com.kitakkun.jetwhale.host.drawer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain +import androidx.compose.runtime.setValue +import com.kitakkun.jetwhale.host.architecture.SoilDataBoundary +import com.kitakkun.jetwhale.host.model.DebugSession +import com.kitakkun.jetwhale.host.model.McpActivity +import com.kitakkun.jetwhale.host.model.McpCapablePlugins +import com.kitakkun.jetwhale.host.model.McpToolSummary +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import soil.query.compose.rememberSubscription +import kotlin.time.Duration.Companion.milliseconds + +/** How long a finished MCP tool call keeps reading as "running" on the screen. */ +private val MCP_TOOLS_RUNNING_LINGER = 1500.milliseconds + +/** One tool row, carrying the plugin that publishes it because the list mixes plugins. */ +data class McpToolRowUiState( + val pluginId: String, + val pluginName: String, + val tool: McpToolSummary, + val callCount: Int, + val running: Boolean, +) { + /** Unique across plugins publishing a tool of the same name. */ + val key: String get() = "$pluginId/${tool.name}" +} + +/** An entry of a filter dropdown. A null [id] is the "All" entry. */ +data class McpFilterOption( + val id: String?, + val label: String, +) + +@Composable +context(screenContext: McpToolsScreenContext) +fun McpToolsScreenRoot( + initialPluginId: String?, + initialSessionId: String?, +) { + SoilDataBoundary( + state1 = rememberSubscription(screenContext.loadedPluginsMetaDataSubscriptionKey), + state2 = rememberSubscription(screenContext.debugSessionsSubscriptionKey), + state3 = rememberSubscription(screenContext.mcpActivitySubscriptionKey), + state4 = rememberSubscription(screenContext.mcpCapablePluginsSubscriptionKey), + ) { loadedPlugins, debugSessions, mcpActivity, mcpCapablePlugins -> + var selectedPluginId by retain { mutableStateOf(initialPluginId) } + var selectedSessionId by retain { mutableStateOf(initialSessionId) } + + // A call can start and finish between two frames, so watching the running list drops fast + // calls entirely. Latch on the monotonic counter and hold briefly instead, matching how the + // drawer decides a plugin is under AI control. + var running by remember { mutableStateOf(false) } + LaunchedEffect(mcpActivity.startedCount) { + if (mcpActivity.startedCount > 0L) { + running = true + delay(MCP_TOOLS_RUNNING_LINGER) + running = false + } + } + val runningInvocation = mcpActivity.lastStartedInvocation?.takeIf { running } + + val pluginNamesById = remember(loadedPlugins) { loadedPlugins.associate { it.id to it.name } } + + McpToolsScreen( + uiState = rememberMcpToolsUiState( + mcpCapablePlugins = mcpCapablePlugins, + mcpActivity = mcpActivity, + debugSessions = debugSessions, + pluginNamesById = pluginNamesById, + selectedPluginId = selectedPluginId, + selectedSessionId = selectedSessionId, + runningPluginId = runningInvocation?.pluginId, + runningToolName = runningInvocation?.toolName, + ), + onSelectPluginFilter = { selectedPluginId = it }, + onSelectSessionFilter = { selectedSessionId = it }, + ) + } +} + +@Composable +private fun rememberMcpToolsUiState( + mcpCapablePlugins: McpCapablePlugins, + mcpActivity: McpActivity, + debugSessions: ImmutableList, + pluginNamesById: Map, + selectedPluginId: String?, + selectedSessionId: String?, + runningPluginId: String?, + runningToolName: String?, +): McpToolsScreenUiState { + val sessionOptions = remember(debugSessions) { + debugSessions + .map { McpFilterOption(id = it.id, label = "${it.deviceDisplayName} · ${it.appDisplayName}") } + .toImmutableList() + } + + // Built from every session so the list of plugins does not shift under the user when they narrow + // the session filter, which would make their own plugin selection disappear. + val pluginOptions = remember(mcpCapablePlugins, mcpActivity.recentCalls, pluginNamesById) { + val publishingIds = mcpCapablePlugins.toolsBySessionAndPlugin.values.flatMap { it.keys } + val calledIds = mcpActivity.recentCalls.mapNotNull { it.pluginId } + (publishingIds + calledIds) + .distinct() + .map { McpFilterOption(id = it, label = pluginNamesById[it] ?: it) } + .sortedBy { it.label } + .toImmutableList() + } + + // Calls that named no session came from a tool that targets none, so they stay visible under a + // specific session too; hiding them would make a session look quieter than it was. + val callHistory = remember(mcpActivity.recentCalls, selectedPluginId, selectedSessionId) { + mcpActivity.recentCalls + .filter { selectedPluginId == null || it.pluginId == selectedPluginId } + .filter { selectedSessionId == null || it.sessionId == null || it.sessionId == selectedSessionId } + .toImmutableList() + } + + val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginId, selectedSessionId, runningPluginId, runningToolName) { + // The same plugin publishes the same tools in every session it is active in, so the scope can + // yield duplicates that carry no extra information. + val callCounts = callHistory.groupingBy { it.pluginId to it.toolName }.eachCount() + mcpCapablePlugins.toolsBySessionAndPlugin + .filterKeys { selectedSessionId == null || it == selectedSessionId } + .values + .flatMap { toolsByPlugin -> toolsByPlugin.entries } + .filter { selectedPluginId == null || it.key == selectedPluginId } + .flatMap { (pluginId, tools) -> tools.map { pluginId to it } } + .distinctBy { (pluginId, tool) -> "$pluginId/${tool.name}" } + .map { (pluginId, tool) -> + McpToolRowUiState( + pluginId = pluginId, + pluginName = pluginNamesById[pluginId] ?: pluginId, + tool = tool, + callCount = callCounts[pluginId to tool.name] ?: 0, + running = pluginId == runningPluginId && tool.name == runningToolName, + ) + } + .sortedWith(compareBy({ it.pluginName }, { it.tool.name })) + .toImmutableList() + } + + return McpToolsScreenUiState( + pluginOptions = pluginOptions, + sessionOptions = sessionOptions, + selectedPluginId = selectedPluginId, + selectedSessionId = selectedSessionId, + toolRows = toolRows, + callHistory = callHistory, + runningToolName = runningToolName, + ) +} diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt index 4461e531..dbe665b5 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt @@ -62,6 +62,7 @@ fun JetWhaleNavDisplay( ) licensesEntry(onClickBack = backStack::removeLastOrNull) logViewerEntry() + mcpToolsEntry() pluginEntries( isOpenedOnPopout = backStack::isPluginPoppedOut, onBringbackToMainWindow = backStack::bringPluginBackToMainWindow, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt index 6aa7ce2c..79f185e3 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt @@ -13,6 +13,20 @@ fun NavBackStack.addSingleTop(index: Int, navKey: T) { add(index, navKey) } +/** + * Shows the MCP tools browser, seeded with the scope it was opened from. + * + * At most one browser window exists: opening it from a different scope re-seeds the filters rather + * than stacking a second window, and re-opening it with the same scope leaves the window as it is so + * the user does not lose its position or their own filter changes. + */ +fun NavBackStack.openMcpTools(pluginId: String?, sessionId: String?) { + val navKey = McpToolsNavKey(pluginId = pluginId, sessionId = sessionId) + if (any { it == navKey }) return + removeAll { it is McpToolsNavKey } + add(navKey) +} + /** * Whether the given plugin is currently shown in a separate popout window for [sessionId]. */ diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt index 9dffec31..b5e77ef6 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt @@ -15,7 +15,9 @@ import androidx.navigation3.runtime.NavKey import com.kitakkun.jetwhale.host.LocalComposeWindow import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.di.JetWhaleAppGraph +import com.kitakkun.jetwhale.host.drawer.McpToolsScreenRoot import com.kitakkun.jetwhale.host.log_viewer_window_title +import com.kitakkun.jetwhale.host.mcp_tools_window_title import com.kitakkun.jetwhale.host.plugin.PluginScreenRoot import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen import com.kitakkun.jetwhale.host.screen.InfoScreen @@ -184,3 +186,33 @@ fun EntryProviderScope.logViewerEntry() { } } } + +context(appGraph: JetWhaleAppGraph) +fun EntryProviderScope.mcpToolsEntry() { + entry( + metadata = WindowSceneStrategy.window( + WindowProperties( + width = 1100.dp, + height = 750.dp, + ), + ), + ) { navKey -> + val window = LocalComposeWindow.current + val windowTitle = stringResource(Res.string.mcp_tools_window_title) + + LaunchedEffect(window, windowTitle) { + window.title = windowTitle + } + + context( + retain { + appGraph.mcpToolsScreenContext + }, + ) { + McpToolsScreenRoot( + initialPluginId = navKey.pluginId, + initialSessionId = navKey.sessionId, + ) + } + } +} From f6e1a66bfef2efc4ffa1c0e19d82074f72d5c0c3 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:08:22 +0900 Subject: [PATCH 10/13] refactor(host): navigate to the MCP browser from the drawer The drawer built a per-plugin tool list and call history purely to hand them to a dialog it owned. Now that the screen subscribes to that data itself, the badge only needs to know whether the plugin publishes tools at all, so `DrawerPluginItemUiState` carries `exposesMcpTools` instead of the tools, the history and the running tool name. Clicking the badge navigates to the browser scoped to that plugin and the selected session. A drawer button beside the settings icon opens it unscoped, so it is reachable without a plugin badge. --- .../com/kitakkun/jetwhale/host/JetWhaleApp.kt | 4 ++ .../jetwhale/host/component/ToolingDrawer.kt | 5 +++ .../host/drawer/DrawerPluginItemUiState.kt | 15 ++----- .../host/drawer/ExpandedToolingDrawerView.kt | 32 ++++++++------- .../host/drawer/JetWhaleToolingScaffold.kt | 6 +++ .../host/drawer/McpToolsDrawerButton.kt | 39 +++++++++++++++++++ .../host/drawer/PluginDrawerItemView.kt | 38 +++++------------- .../host/drawer/ShrunkToolingDrawerView.kt | 4 ++ .../host/drawer/ToolingScaffoldPresenter.kt | 17 +------- .../host/drawer/ToolingScaffoldRoot.kt | 5 +++ 10 files changed, 95 insertions(+), 70 deletions(-) create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt index 7d880e1f..136b8d49 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt @@ -35,6 +35,7 @@ import com.kitakkun.jetwhale.host.navigation.addSingleTop import com.kitakkun.jetwhale.host.navigation.bringPluginBackToMainWindow import com.kitakkun.jetwhale.host.navigation.followPluginToSession import com.kitakkun.jetwhale.host.navigation.isPluginPoppedOut +import com.kitakkun.jetwhale.host.navigation.openMcpTools import com.kitakkun.jetwhale.host.settings.SettingsScreenSegmentedMenu import com.kitakkun.jetwhale.host.ui.AppEnvironment import com.kitakkun.jetwhale.host.ui.JetWhaleTheme @@ -139,6 +140,9 @@ fun JetWhaleApp() { onClickPlugin = { pluginId, sessionId -> backStack.addSingleTop(PluginNavKey(pluginId, sessionId)) }, + onOpenMcpTools = { pluginId, sessionId -> + backStack.openMcpTools(pluginId = pluginId, sessionId = sessionId) + }, onClickPopout = { pluginId, pluginName, sessionId -> backStack.addSingleTop( PluginPopoutNavKey( diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt index 4af76e3e..ebdb3934 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt @@ -24,6 +24,8 @@ fun ToolingDrawer( onClickPluginSettings: () -> Unit, onClickInfo: () -> Unit, onClickPlugin: (String) -> Unit, + onOpenMcpTools: (pluginId: String) -> Unit, + onOpenAllMcpTools: () -> Unit, onSelectSession: (DebugSession) -> Unit, onClickPopout: (DrawerPluginItemUiState) -> Unit, isPoppedOut: (pluginId: String) -> Boolean, @@ -45,6 +47,8 @@ fun ToolingDrawer( onClickShrinkDrawer = { expandMenu = false }, onClickSettings = onClickSettings, onClickPluginSettings = onClickPluginSettings, + onOpenMcpTools = onOpenMcpTools, + onOpenAllMcpTools = onOpenAllMcpTools, onClickPlugin = { onClickPlugin(it.id) }, onSelectSession = onSelectSession, onClickPopout = onClickPopout, @@ -64,6 +68,7 @@ fun ToolingDrawer( onClickExpandMenu = { expandMenu = true }, onClickSettings = onClickSettings, onClickInfo = onClickInfo, + onOpenAllMcpTools = onOpenAllMcpTools, onSelectSession = onSelectSession, ) }, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt index d4eae91e..85fba52b 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt @@ -1,10 +1,7 @@ package com.kitakkun.jetwhale.host.drawer -import com.kitakkun.jetwhale.host.model.McpCallRecord -import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginAvailability import com.kitakkun.jetwhale.host.model.PluginIconResource -import kotlinx.collections.immutable.ImmutableList data class DrawerPluginItemUiState( val name: String, @@ -14,12 +11,6 @@ data class DrawerPluginItemUiState( val pluginAvailability: PluginAvailability, /** True while an AI agent is driving this plugin's UI in the selected session. */ val underAiControl: Boolean, - /** The MCP tools this plugin exposes for the selected session; empty when it publishes none. */ - val mcpTools: ImmutableList, - /** Completed MCP tool calls attributed to this plugin, newest first. */ - val mcpCallHistory: ImmutableList, - /** The tool an agent is running on this plugin right now, or null when none is in flight. */ - val runningMcpToolName: String?, -) { - val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty() -} + /** True when this plugin publishes MCP tools of its own for the selected session. */ + val exposesMcpTools: Boolean, +) diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt index 53a63c8a..ca9d13b7 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt @@ -64,6 +64,8 @@ fun ExpandedToolingDrawerView( onClickShrinkDrawer: () -> Unit, onClickSettings: () -> Unit, onClickPluginSettings: () -> Unit, + onOpenMcpTools: (pluginId: String) -> Unit, + onOpenAllMcpTools: () -> Unit, onClickPlugin: (DrawerPluginItemUiState) -> Unit, onSelectSession: (DebugSession) -> Unit, onClickPopout: (DrawerPluginItemUiState) -> Unit, @@ -82,11 +84,16 @@ fun ExpandedToolingDrawerView( contentDescription = null, ) } - IconButton(onClick = onClickSettings) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - ) + Row { + // Opens the browser unscoped, so the tools an agent can reach are visible without + // first finding a plugin that happens to publish some. + McpToolsDrawerButton(onClick = onOpenAllMcpTools) + IconButton(onClick = onClickSettings) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + } } } Column( @@ -169,9 +176,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = it.id == selectedPluginId, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, - mcpCallHistory = it.mcpCallHistory, - runningMcpToolName = it.runningMcpToolName, + exposesMcpTools = it.exposesMcpTools, + onClickMcpBadge = { onOpenMcpTools(it.id) }, onClick = { onClickPlugin(it) }, popupMenuContent = { dismiss -> DropdownMenuItem( @@ -243,9 +249,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = false, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, - mcpCallHistory = it.mcpCallHistory, - runningMcpToolName = it.runningMcpToolName, + exposesMcpTools = it.exposesMcpTools, + onClickMcpBadge = { onOpenMcpTools(it.id) }, onClick = { // do nothing }, @@ -291,9 +296,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = false, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, - mcpCallHistory = it.mcpCallHistory, - runningMcpToolName = it.runningMcpToolName, + exposesMcpTools = it.exposesMcpTools, + onClickMcpBadge = { onOpenMcpTools(it.id) }, onClick = { // do nothing }, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt index 3784c31c..75c0bcdd 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt @@ -19,6 +19,8 @@ fun ToolingScaffold( onClickPluginSettings: () -> Unit, onClickInfo: () -> Unit, onClickPlugin: (String) -> Unit, + onOpenMcpTools: (pluginId: String) -> Unit, + onOpenAllMcpTools: () -> Unit, onClickPopout: (DrawerPluginItemUiState) -> Unit, isPoppedOut: (pluginId: String) -> Boolean, onClickBringBack: (DrawerPluginItemUiState) -> Unit, @@ -41,6 +43,8 @@ fun ToolingScaffold( onClickPluginSettings = onClickPluginSettings, onClickInfo = onClickInfo, onClickPlugin = onClickPlugin, + onOpenMcpTools = onOpenMcpTools, + onOpenAllMcpTools = onOpenAllMcpTools, onSelectSession = onSelectSession, onClickPopout = onClickPopout, isPoppedOut = isPoppedOut, @@ -72,6 +76,8 @@ private fun ToolingScaffoldPreview() { onClickPluginSettings = {}, onClickInfo = {}, onClickPlugin = {}, + onOpenMcpTools = {}, + onOpenAllMcpTools = {}, onSelectSession = {}, onClickPopout = {}, isPoppedOut = { false }, diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt new file mode 100644 index 00000000..6929a689 --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt @@ -0,0 +1,39 @@ +package com.kitakkun.jetwhale.host.drawer + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import com.kitakkun.jetwhale.host.Res +import com.kitakkun.jetwhale.host.mcp_tools_open_all +import org.jetbrains.compose.resources.stringResource + +/** + * Drawer entry point to the MCP tools browser that is not tied to a plugin, so the browser is + * reachable even when no plugin badge is on screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpToolsDrawerButton(onClick: () -> Unit) { + val label = stringResource(Res.string.mcp_tools_open_all) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(label) } }, + state = rememberTooltipState(), + ) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = label, + ) + } + } +} diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt index 6c198165..ba60d73b 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt @@ -33,12 +33,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.kitakkun.jetwhale.host.Res -import com.kitakkun.jetwhale.host.model.McpCallRecord -import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginIconResource import com.kitakkun.jetwhale.host.puzzle_filled import com.kitakkun.jetwhale.host.puzzle_outlined -import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.painterResource @Composable @@ -47,12 +44,11 @@ fun PluginDrawerItemView( name: String, selected: Boolean, underAiControl: Boolean, - mcpTools: ImmutableList, - mcpCallHistory: ImmutableList, - runningMcpToolName: String?, + exposesMcpTools: Boolean, activeIconResource: PluginIconResource?, inactiveIconResource: PluginIconResource?, onClick: () -> Unit, + onClickMcpBadge: () -> Unit, popupMenuContent: (@Composable ColumnScope.(dismiss: () -> Unit) -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -73,13 +69,10 @@ fun PluginDrawerItemView( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (mcpTools.isNotEmpty()) { + if (exposesMcpTools) { McpBadge( operating = underAiControl, - pluginName = name, - tools = mcpTools, - callHistory = mcpCallHistory, - runningToolName = runningMcpToolName, + onClick = onClickMcpBadge, ) } } @@ -138,17 +131,14 @@ fun PluginDrawerItemView( /** * A compact "MCP" badge shown after a plugin's name when it exposes MCP tools. It is filled while an - * agent is running one of those tools and outlined otherwise, and opens a dialog listing the tools. + * agent is running one of those tools and outlined otherwise, and opens the MCP tools browser scoped + * to this plugin. */ @Composable private fun McpBadge( operating: Boolean, - pluginName: String, - tools: ImmutableList, - callHistory: ImmutableList, - runningToolName: String?, + onClick: () -> Unit, ) { - var showDialog by remember { mutableStateOf(false) } val shape = RoundedCornerShape(4.dp) val contentColor = if (operating) Color.Black else MaterialTheme.colorScheme.onSurfaceVariant val decoration = if (operating) { @@ -160,7 +150,7 @@ private fun McpBadge( modifier = Modifier .clip(shape) .then(decoration) - .clickable { showDialog = true } + .clickable(onClick = onClick) .padding(horizontal = 5.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp), @@ -170,7 +160,7 @@ private fun McpBadge( style = MaterialTheme.typography.labelSmall, color = contentColor, ) - // Signals that clicking opens a separate dialog. + // Signals that clicking opens a separate window. Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, @@ -178,14 +168,4 @@ private fun McpBadge( modifier = Modifier.size(11.dp), ) } - if (showDialog) { - McpToolsDialog( - operating = operating, - pluginName = pluginName, - tools = tools, - callHistory = callHistory, - runningToolName = runningToolName, - onDismiss = { showDialog = false }, - ) - } } diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt index 3938bd76..04af84c4 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt @@ -61,6 +61,7 @@ fun ShrunkToolingDrawerView( onClickSettings: () -> Unit, onClickPlugin: (String) -> Unit, onClickInfo: () -> Unit, + onOpenAllMcpTools: () -> Unit, onSelectSession: (DebugSession) -> Unit, ) { Column( @@ -126,6 +127,9 @@ fun ShrunkToolingDrawerView( contentDescription = null, ) } + // Opens the browser unscoped, so the tools an agent can reach are visible without first + // finding a plugin that happens to publish some. + McpToolsDrawerButton(onClick = onOpenAllMcpTools) CompactAiActivityIndicatorView(uiState = aiActivity) HorizontalDivider() LazyColumn( diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt index 79dfc892..5dff2056 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt @@ -72,8 +72,7 @@ fun toolingScaffoldPresenter( val setPluginEnabledMutation = rememberMutation(presenterContext.setPluginEnabledMutationKey) - val recentCalls = mcpActivity.recentCalls - val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation, recentCalls) { + val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation) { derivedStateOf { // Attribute the operation only when it targets the session the drawer is showing; // highlighting a plugin for some other device would be misleading. @@ -81,12 +80,6 @@ fun toolingScaffoldPresenter( ?.takeIf { it.sessionId != null && it.sessionId == selectedSession?.id } ?.pluginId - // Calls that named no session came from a tool that does not target one, so they belong - // to whichever session is on screen; the rest are shown only under their own session. - val callsForSelectedSession = recentCalls.filter { - it.sessionId == null || it.sessionId == selectedSession?.id - } - loadedPlugins.map { metaData -> val isInstalledOnAgent = selectedSession?.installedPlugins?.any { installed -> installed.pluginId == metaData.id } == true val isEnabledInSettings = enabledPluginIds.contains(metaData.id) @@ -108,13 +101,7 @@ fun toolingScaffoldPresenter( else -> PluginAvailability.Disabled }, underAiControl = aiControlledPluginId == metaData.id, - mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(), - mcpCallHistory = callsForSelectedSession - .filter { it.pluginId == metaData.id } - .toImmutableList(), - runningMcpToolName = activeInvocation - ?.takeIf { it.pluginId == metaData.id } - ?.toolName, + exposesMcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).isNotEmpty(), ) }.toImmutableList() } diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt index 39a22fdc..55aa63b0 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt @@ -15,6 +15,7 @@ fun ToolingScaffoldRoot( onClickPluginSettings: () -> Unit, onClickInfo: () -> Unit, onClickPlugin: (pluginId: String, sessionId: String) -> Unit, + onOpenMcpTools: (pluginId: String?, sessionId: String?) -> Unit, onClickPopout: (pluginId: String, pluginName: String, sessionId: String) -> Unit, isPoppedOut: (pluginId: String, sessionId: String) -> Boolean, onClickBringBack: (pluginId: String, sessionId: String) -> Unit, @@ -68,6 +69,10 @@ fun ToolingScaffoldRoot( screenChannel.send(ToolingScaffoldScreenAction.UpdateSelectedPlugin(it)) onClickPlugin(it, selectedSession.id) }, + // The browser tolerates a missing session, so the badge stays usable while no session + // is selected: it simply opens with the session filter on "All". + onOpenMcpTools = { onOpenMcpTools(it, uiState.selectedSession?.id) }, + onOpenAllMcpTools = { onOpenMcpTools(null, null) }, onClickPopout = { val selectedSession = uiState.selectedSession ?: return@ToolingScaffold onClickPopout(it.id, it.name, selectedSession.id) From 0037d5410b7f00c9e99e7f5299c943b5a2dc6399 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:09:30 +0900 Subject: [PATCH 11/13] fix(host): record isError tool results as failed calls MCP lets a tool report its own failure by returning a result flagged with `isError` instead of throwing, which is how the built-in tools signal a rejected click or drag. The registrar only watched for a thrown handler, so those calls landed in history marked as successes. Also fold `structuredContent` into the recorded response so a tool that answers with a machine-readable payload is not shown as an empty result. --- .../jetwhale/host/mcp/McpToolRegistrar.kt | 22 +++-- .../host/mcp/DefaultMcpServerServiceTest.kt | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index bb3bfe3f..77de1d5a 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -87,13 +87,14 @@ class McpToolRegistrar( value.jsonContent ?: value.toString() }, ) - // A thrown handler is the only failure signal available here, and it has to reach the - // repository before it propagates on to the MCP layer. + // A tool fails in two ways: the handler throws, or it returns a result flagged with + // `isError`, which is how the protocol wants a tool-level failure reported. Both have to + // reach the repository before the call leaves here. var failed = true var response = "" try { handler(request).also { - failed = false + failed = it.isError == true response = it.renderForHistory() } } catch (throwable: Throwable) { @@ -113,10 +114,17 @@ class McpToolRegistrar( * Only text blocks carry something a reader can use; every other block type is a binary payload (a * screenshot, audio, an embedded resource) that is named rather than inlined, so history does not * fill up with base64. + * + * A structured payload follows the blocks on its own line. It is the whole answer for a tool that + * replies only in `structuredContent`, and it reads as the machine-readable detail behind the prose + * for a tool that sends both. */ -private fun CallToolResult.renderForHistory(): String = content.joinToString(separator = "\n") { block -> - when (block) { - is TextContent -> block.text - else -> "<${block.type.value}>" +private fun CallToolResult.renderForHistory(): String { + val renderedBlocks = content.map { block -> + when (block) { + is TextContent -> block.text + else -> "<${block.type.value}>" + } } + return (renderedBlocks + listOfNotNull(structuredContent?.toString())).joinToString(separator = "\n") } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index 0d30b96e..c8fe4ac3 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -469,6 +471,62 @@ class DefaultMcpServerServiceTest { assertEquals("captured\n", record.response) } + @Test + fun `a tool call that reports an error without throwing is recorded as a failure`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(ErrorResultMcpTool("fake.rejected")), + ) + val rejectedPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, rejectedPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$rejectedPort/sse") + try { + val result = client.callTool("fake.rejected", emptyMap()) + // The handler returns normally, so nothing but `isError` marks this as a failure. + assertEquals(true, result.isError) + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertEquals("fake.rejected", record.toolName) + assertFalse(record.succeeded) + assertEquals("""{"error":"no such element"}""", record.response) + } + + @Test + fun `a structured response is recorded alongside the text content`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(StructuredMcpTool("fake.structured")), + ) + val structuredPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, structuredPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$structuredPort/sse") + try { + client.callTool("fake.structured", emptyMap()) + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertTrue(record.succeeded) + assertEquals("measured", record.response.lineSequence().first()) + assertTrue("\"width\":120" in record.response, "Structured payload missing from ${record.response}") + } + @Test fun `a throwing tool call is recorded in history as a failure`() = runBlocking { val serviceWithTool = DefaultMcpServerService( @@ -584,6 +642,30 @@ private class MediaMcpTool(private val name: String) : JetWhaleMcpTool { } } +/** Reports a tool-level failure the way the protocol prefers: a normal return flagged `isError`. */ +private class ErrorResultMcpTool(private val name: String) : JetWhaleMcpTool { + override fun register(registrar: McpToolRegistrar) { + registrar.addTool(name = name, description = "Always reports an error result", inputSchema = ToolSchema()) { _ -> + errorResult("no such element") + } + } +} + +/** Answers with both prose and a machine-readable payload, as a tool with an output schema does. */ +private class StructuredMcpTool(private val name: String) : JetWhaleMcpTool { + override fun register(registrar: McpToolRegistrar) { + registrar.addTool(name = name, description = "Returns structured content", inputSchema = ToolSchema()) { _ -> + CallToolResult( + content = listOf(TextContent("measured")), + structuredContent = buildJsonObject { + put("width", 120) + put("height", 40) + }, + ) + } + } +} + private class FailingMcpTool(private val name: String) : JetWhaleMcpTool { override fun register(registrar: McpToolRegistrar) { registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ -> From 5b3b6ea631a3397bc567b19b0633b71cd06d0dd7 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:32:51 +0900 Subject: [PATCH 12/13] feat(host): filter the MCP browser by several plugins and sessions The plugin and session filters accepted a single value, so two plugins could not be looked at side by side. Both now hold a set of ids, and an empty set means every value passes. Each picked value gets its own removable `InputChip` with a close icon; the group falls back to an "All" chip that opens the picker while its set is empty. The picker keeps a check beside the entries already chosen and stays open across picks so several can be added in one go. --- .../composeResources/values-ja/strings.xml | 2 + .../main/composeResources/values/strings.xml | 2 + .../jetwhale/host/drawer/McpToolsScreen.kt | 196 +++++++++++++----- .../host/drawer/McpToolsScreenRoot.kt | 39 ++-- 4 files changed, 172 insertions(+), 67 deletions(-) diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index 699f4b4d..96f2d694 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -55,4 +55,6 @@ プラグイン セッション すべて + フィルターを追加 + フィルターを解除 diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index 7a77507d..ebc9cf9b 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -55,4 +55,6 @@ Plugin Session All + Add filter + Remove filter diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt index 03a2b8d4..a037a204 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -23,16 +24,20 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AssistChip import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -67,8 +72,10 @@ import com.kitakkun.jetwhale.host.mcp_history_response import com.kitakkun.jetwhale.host.mcp_history_succeeded import com.kitakkun.jetwhale.host.mcp_tool_executing import com.kitakkun.jetwhale.host.mcp_tools_available +import com.kitakkun.jetwhale.host.mcp_tools_filter_add import com.kitakkun.jetwhale.host.mcp_tools_filter_all import com.kitakkun.jetwhale.host.mcp_tools_filter_plugin +import com.kitakkun.jetwhale.host.mcp_tools_filter_remove import com.kitakkun.jetwhale.host.mcp_tools_filter_session import com.kitakkun.jetwhale.host.mcp_tools_no_match import com.kitakkun.jetwhale.host.mcp_tools_parameters @@ -79,6 +86,7 @@ import com.kitakkun.jetwhale.host.mcp_tools_tab_tools import com.kitakkun.jetwhale.host.model.McpCallRecord import com.kitakkun.jetwhale.host.model.McpToolParameterSummary import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import org.jetbrains.compose.resources.stringResource import java.time.Instant import java.time.ZoneId @@ -88,8 +96,8 @@ import java.time.format.DateTimeFormatter data class McpToolsScreenUiState( val pluginOptions: ImmutableList, val sessionOptions: ImmutableList, - val selectedPluginId: String?, - val selectedSessionId: String?, + val selectedPluginIds: ImmutableSet, + val selectedSessionIds: ImmutableSet, val toolRows: ImmutableList, val callHistory: ImmutableList, val runningToolName: String?, @@ -104,8 +112,8 @@ internal enum class McpToolsTab { @Composable fun McpToolsScreen( uiState: McpToolsScreenUiState, - onSelectPluginFilter: (String?) -> Unit, - onSelectSessionFilter: (String?) -> Unit, + onSelectPluginFilters: (Set) -> Unit, + onSelectSessionFilters: (Set) -> Unit, ) { Column( modifier = Modifier @@ -113,29 +121,35 @@ fun McpToolsScreen( .padding(20.dp), ) { Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { - McpFilterDropdown( - label = stringResource(Res.string.mcp_tools_filter_plugin), - options = uiState.pluginOptions, - selectedId = uiState.selectedPluginId, - onSelect = onSelectPluginFilter, - ) - McpFilterDropdown( - label = stringResource(Res.string.mcp_tools_filter_session), - options = uiState.sessionOptions, - selectedId = uiState.selectedSessionId, - onSelect = onSelectSessionFilter, - ) - Spacer(Modifier.weight(1f)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + McpFilterChipGroup( + label = stringResource(Res.string.mcp_tools_filter_plugin), + options = uiState.pluginOptions, + selectedIds = uiState.selectedPluginIds, + onSelectionChange = onSelectPluginFilters, + ) + McpFilterChipGroup( + label = stringResource(Res.string.mcp_tools_filter_session), + options = uiState.sessionOptions, + selectedIds = uiState.selectedSessionIds, + onSelectionChange = onSelectSessionFilters, + ) + } Text( text = stringResource( if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, ), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + // Held against the first chip row instead of the middle of a block whose height + // grows as chips wrap. + modifier = Modifier.padding(top = 8.dp), ) } Spacer(Modifier.size(12.dp)) @@ -190,51 +204,135 @@ fun McpToolsScreen( } } -/** "All" plus one entry per option, so the user can widen a filter the nav key seeded. */ +/** Icon size shared by the filter chips and the entries of their picker. */ +private val McpFilterIconSize = 18.dp + +/** + * One filter group: a removable chip per picked value, plus a chip that opens the picker. An empty + * [selectedIds] narrows nothing, and the group reads as "All" until a value is picked. + */ @Composable -private fun McpFilterDropdown( +private fun McpFilterChipGroup( label: String, options: ImmutableList, - selectedId: String?, - onSelect: (String?) -> Unit, + selectedIds: ImmutableSet, + onSelectionChange: (Set) -> Unit, ) { var expanded by remember { mutableStateOf(false) } val allLabel = stringResource(Res.string.mcp_tools_filter_all) - val selectedLabel = options.firstOrNull { it.id == selectedId }?.label ?: allLabel + val addLabel = stringResource(Res.string.mcp_tools_filter_add) + val removeLabel = stringResource(Res.string.mcp_tools_filter_remove) + // A picked value outlives the option that named it once a session goes away, so the raw id + // stands in rather than dropping a filter that is still narrowing the screen. + val selectedChips = remember(options, selectedIds) { + val labelsById = options.associate { it.id to it.label } + selectedIds + .map { id -> McpFilterOption(id = id, label = labelsById[id] ?: id) } + .sortedBy { it.label } + } - Box { - OutlinedButton(onClick = { expanded = true }) { - Text( - text = "$label: $selectedLabel", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 260.dp), - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Fixed width and held against the first chip row so both groups' labels line up even + // when one of them wraps onto several rows. + modifier = Modifier + .width(76.dp) + .padding(top = 8.dp), + ) + // Wraps so a long list of plugins or sessions grows downwards instead of widening a dialog + // that is already bounded. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), ) { - DropdownMenuItem( - text = { Text(allLabel) }, - onClick = { - onSelect(null) - expanded = false - }, - ) - options.forEach { option -> - DropdownMenuItem( - text = { Text(option.label) }, - onClick = { - onSelect(option.id) - expanded = false + selectedChips.forEach { option -> + InputChip( + selected = true, + onClick = { onSelectionChange(selectedIds - option.id) }, + label = { + Text( + text = option.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = removeLabel, + modifier = Modifier.size(McpFilterIconSize), + ) }, ) } + Box { + val filtering = selectedChips.isNotEmpty() + val addIcon: (@Composable () -> Unit)? = if (filtering) { + { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(McpFilterIconSize), + ) + } + } else { + null + } + AssistChip( + onClick = { expanded = true }, + enabled = options.isNotEmpty(), + label = { Text(if (filtering) addLabel else allLabel) }, + leadingIcon = addIcon, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // The menu survives every pick so several values can be added in one go, and a + // check marks what is already picked while it is open. + DropdownMenuItem( + text = { Text(allLabel) }, + onClick = { onSelectionChange(emptySet()) }, + trailingIcon = { if (selectedIds.isEmpty()) McpFilterSelectedCheck() }, + ) + options.forEach { option -> + val selected = option.id in selectedIds + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + onSelectionChange( + if (selected) selectedIds - option.id else selectedIds + option.id, + ) + }, + trailingIcon = { if (selected) McpFilterSelectedCheck() }, + ) + } + } + } } } } +/** Marks an entry of a filter picker as already narrowing the screen. */ +@Composable +private fun McpFilterSelectedCheck() { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(McpFilterIconSize), + ) +} + /** * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt index 473c1b76..ff9a583e 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt @@ -13,7 +13,9 @@ import com.kitakkun.jetwhale.host.model.McpActivity import com.kitakkun.jetwhale.host.model.McpCapablePlugins import com.kitakkun.jetwhale.host.model.McpToolSummary import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.delay import soil.query.compose.rememberSubscription import kotlin.time.Duration.Companion.milliseconds @@ -33,9 +35,9 @@ data class McpToolRowUiState( val key: String get() = "$pluginId/${tool.name}" } -/** An entry of a filter dropdown. A null [id] is the "All" entry. */ +/** One value a filter group can be narrowed to. */ data class McpFilterOption( - val id: String?, + val id: String, val label: String, ) @@ -51,8 +53,9 @@ fun McpToolsScreenRoot( state3 = rememberSubscription(screenContext.mcpActivitySubscriptionKey), state4 = rememberSubscription(screenContext.mcpCapablePluginsSubscriptionKey), ) { loadedPlugins, debugSessions, mcpActivity, mcpCapablePlugins -> - var selectedPluginId by retain { mutableStateOf(initialPluginId) } - var selectedSessionId by retain { mutableStateOf(initialSessionId) } + // An empty set filters nothing, so the nav key's "no preference" maps onto it directly. + var selectedPluginIds by retain { mutableStateOf(setOfNotNull(initialPluginId).toPersistentSet()) } + var selectedSessionIds by retain { mutableStateOf(setOfNotNull(initialSessionId).toPersistentSet()) } // A call can start and finish between two frames, so watching the running list drops fast // calls entirely. Latch on the monotonic counter and hold briefly instead, matching how the @@ -75,13 +78,13 @@ fun McpToolsScreenRoot( mcpActivity = mcpActivity, debugSessions = debugSessions, pluginNamesById = pluginNamesById, - selectedPluginId = selectedPluginId, - selectedSessionId = selectedSessionId, + selectedPluginIds = selectedPluginIds, + selectedSessionIds = selectedSessionIds, runningPluginId = runningInvocation?.pluginId, runningToolName = runningInvocation?.toolName, ), - onSelectPluginFilter = { selectedPluginId = it }, - onSelectSessionFilter = { selectedSessionId = it }, + onSelectPluginFilters = { selectedPluginIds = it.toPersistentSet() }, + onSelectSessionFilters = { selectedSessionIds = it.toPersistentSet() }, ) } } @@ -92,8 +95,8 @@ private fun rememberMcpToolsUiState( mcpActivity: McpActivity, debugSessions: ImmutableList, pluginNamesById: Map, - selectedPluginId: String?, - selectedSessionId: String?, + selectedPluginIds: ImmutableSet, + selectedSessionIds: ImmutableSet, runningPluginId: String?, runningToolName: String?, ): McpToolsScreenUiState { @@ -117,22 +120,22 @@ private fun rememberMcpToolsUiState( // Calls that named no session came from a tool that targets none, so they stay visible under a // specific session too; hiding them would make a session look quieter than it was. - val callHistory = remember(mcpActivity.recentCalls, selectedPluginId, selectedSessionId) { + val callHistory = remember(mcpActivity.recentCalls, selectedPluginIds, selectedSessionIds) { mcpActivity.recentCalls - .filter { selectedPluginId == null || it.pluginId == selectedPluginId } - .filter { selectedSessionId == null || it.sessionId == null || it.sessionId == selectedSessionId } + .filter { selectedPluginIds.isEmpty() || it.pluginId in selectedPluginIds } + .filter { selectedSessionIds.isEmpty() || it.sessionId == null || it.sessionId in selectedSessionIds } .toImmutableList() } - val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginId, selectedSessionId, runningPluginId, runningToolName) { + val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginIds, selectedSessionIds, runningPluginId, runningToolName) { // The same plugin publishes the same tools in every session it is active in, so the scope can // yield duplicates that carry no extra information. val callCounts = callHistory.groupingBy { it.pluginId to it.toolName }.eachCount() mcpCapablePlugins.toolsBySessionAndPlugin - .filterKeys { selectedSessionId == null || it == selectedSessionId } + .filterKeys { selectedSessionIds.isEmpty() || it in selectedSessionIds } .values .flatMap { toolsByPlugin -> toolsByPlugin.entries } - .filter { selectedPluginId == null || it.key == selectedPluginId } + .filter { selectedPluginIds.isEmpty() || it.key in selectedPluginIds } .flatMap { (pluginId, tools) -> tools.map { pluginId to it } } .distinctBy { (pluginId, tool) -> "$pluginId/${tool.name}" } .map { (pluginId, tool) -> @@ -151,8 +154,8 @@ private fun rememberMcpToolsUiState( return McpToolsScreenUiState( pluginOptions = pluginOptions, sessionOptions = sessionOptions, - selectedPluginId = selectedPluginId, - selectedSessionId = selectedSessionId, + selectedPluginIds = selectedPluginIds, + selectedSessionIds = selectedSessionIds, toolRows = toolRows, callHistory = callHistory, runningToolName = runningToolName, From f8b43225685d318d5e8de079752439b579fb0338 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:34:11 +0900 Subject: [PATCH 13/13] fix(host): open the MCP browser as a dialog inside the window A separate window did not inherit the app theme, leaving the browser unreadable on a white background. Register it with the dialog strategy used by the other dialogs, disable the platform default width so the browser can size itself, and give it the same window-fraction sizing the tool dialog used before it became a screen. --- .../composeResources/values-ja/strings.xml | 1 - .../main/composeResources/values/strings.xml | 1 - .../jetwhale/host/drawer/McpToolsScreen.kt | 166 ++++++++++-------- .../jetwhale/host/navigation/NavEntries.kt | 16 +- 4 files changed, 94 insertions(+), 90 deletions(-) diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml index 96f2d694..482f35c0 100644 --- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml @@ -50,7 +50,6 @@ 引数なし レスポンス レスポンスなし - MCP Tools MCP tools を閲覧 プラグイン セッション diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml index ebc9cf9b..ccd2be5c 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -50,7 +50,6 @@ No arguments Response No response - MCP Tools Browse MCP tools Plugin Session diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt index a037a204..b8ba948b 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -39,6 +40,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -109,97 +111,109 @@ internal enum class McpToolsTab { History, } +private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f + @Composable fun McpToolsScreen( uiState: McpToolsScreenUiState, onSelectPluginFilters: (Set) -> Unit, onSelectSessionFilters: (Set) -> Unit, ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), + Surface(shape = MaterialTheme.shapes.large) { + Column( + modifier = Modifier + // Take most of the window so tool descriptions and history are readable, but stop + // growing past a comfortable reading width on a large display. + .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION) + .sizeIn( + minWidth = 640.dp, + minHeight = 440.dp, + maxWidth = 1200.dp, + maxHeight = 860.dp, + ) + .padding(20.dp), ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f), + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), ) { - McpFilterChipGroup( - label = stringResource(Res.string.mcp_tools_filter_plugin), - options = uiState.pluginOptions, - selectedIds = uiState.selectedPluginIds, - onSelectionChange = onSelectPluginFilters, - ) - McpFilterChipGroup( - label = stringResource(Res.string.mcp_tools_filter_session), - options = uiState.sessionOptions, - selectedIds = uiState.selectedSessionIds, - onSelectionChange = onSelectSessionFilters, + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + McpFilterChipGroup( + label = stringResource(Res.string.mcp_tools_filter_plugin), + options = uiState.pluginOptions, + selectedIds = uiState.selectedPluginIds, + onSelectionChange = onSelectPluginFilters, + ) + McpFilterChipGroup( + label = stringResource(Res.string.mcp_tools_filter_session), + options = uiState.sessionOptions, + selectedIds = uiState.selectedSessionIds, + onSelectionChange = onSelectSessionFilters, + ) + } + Text( + text = stringResource( + if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Held against the first chip row instead of the middle of a block whose height + // grows as chips wrap. + modifier = Modifier.padding(top = 8.dp), ) } - Text( - text = stringResource( - if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - // Held against the first chip row instead of the middle of a block whose height - // grows as chips wrap. - modifier = Modifier.padding(top = 8.dp), - ) - } - Spacer(Modifier.size(12.dp)) + Spacer(Modifier.size(12.dp)) - var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) } - TabRow(selectedTabIndex = selectedTab.ordinal) { - Tab( - selected = selectedTab == McpToolsTab.Tools, - onClick = { selectedTab = McpToolsTab.Tools }, - text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, - ) - Tab( - selected = selectedTab == McpToolsTab.History, - onClick = { selectedTab = McpToolsTab.History }, - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text(stringResource(Res.string.mcp_tools_tab_history)) - if (uiState.callHistory.isNotEmpty()) { - // How many calls the current scope holds, so the count is visible - // without opening the tab. - McpToolCallCountBadge(count = uiState.callHistory.size, running = false) + var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) } + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == McpToolsTab.Tools, + onClick = { selectedTab = McpToolsTab.Tools }, + text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) }, + ) + Tab( + selected = selectedTab == McpToolsTab.History, + onClick = { selectedTab = McpToolsTab.History }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(stringResource(Res.string.mcp_tools_tab_history)) + if (uiState.callHistory.isNotEmpty()) { + // How many calls the current scope holds, so the count is visible + // without opening the tab. + McpToolCallCountBadge(count = uiState.callHistory.size, running = false) + } } - } - }, - ) - } - Spacer(Modifier.size(12.dp)) + }, + ) + } + Spacer(Modifier.size(12.dp)) - // Hoisted out of the pane so switching tabs and coming back keeps the search and the - // selected tool where the user left them. - var query by remember { mutableStateOf("") } - var selectedToolKey by remember { mutableStateOf(null) } + // Hoisted out of the pane so switching tabs and coming back keeps the search and the + // selected tool where the user left them. + var query by remember { mutableStateOf("") } + var selectedToolKey by remember { mutableStateOf(null) } - when (selectedTab) { - McpToolsTab.Tools -> McpToolsPane( - toolRows = uiState.toolRows, - query = query, - onQueryChange = { query = it }, - selectedToolKey = selectedToolKey, - onSelectTool = { selectedToolKey = it }, - modifier = Modifier.weight(1f), - ) + when (selectedTab) { + McpToolsTab.Tools -> McpToolsPane( + toolRows = uiState.toolRows, + query = query, + onQueryChange = { query = it }, + selectedToolKey = selectedToolKey, + onSelectTool = { selectedToolKey = it }, + modifier = Modifier.weight(1f), + ) - McpToolsTab.History -> McpCallHistoryPane( - callHistory = uiState.callHistory, - modifier = Modifier.weight(1f), - ) + McpToolsTab.History -> McpCallHistoryPane( + callHistory = uiState.callHistory, + modifier = Modifier.weight(1f), + ) + } } } } diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt index b5e77ef6..9ae5327b 100644 --- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt @@ -17,7 +17,6 @@ import com.kitakkun.jetwhale.host.Res import com.kitakkun.jetwhale.host.di.JetWhaleAppGraph import com.kitakkun.jetwhale.host.drawer.McpToolsScreenRoot import com.kitakkun.jetwhale.host.log_viewer_window_title -import com.kitakkun.jetwhale.host.mcp_tools_window_title import com.kitakkun.jetwhale.host.plugin.PluginScreenRoot import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen import com.kitakkun.jetwhale.host.screen.InfoScreen @@ -190,20 +189,13 @@ fun EntryProviderScope.logViewerEntry() { context(appGraph: JetWhaleAppGraph) fun EntryProviderScope.mcpToolsEntry() { entry( - metadata = WindowSceneStrategy.window( - WindowProperties( - width = 1100.dp, - height = 750.dp, + // The browser sizes itself; the platform default width would squeeze it to a narrow column. + metadata = StableDialogSceneStrategy.dialog( + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, ), ), ) { navKey -> - val window = LocalComposeWindow.current - val windowTitle = stringResource(Res.string.mcp_tools_window_title) - - LaunchedEffect(window, windowTitle) { - window.title = windowTitle - } - context( retain { appGraph.mcpToolsScreenContext