fix(native): render plan proposals inline

Render ExitPlanMode and exit_plan_mode Markdown from input.plan in iOS and Android conversations. Preserve approvals, raw source and diagnostics while hiding empty output placeholders and prewarming plan documents.

Add generated protocol fixtures and native regression coverage for long plans, live updates, recycling, themes and typography.
This commit is contained in:
weishu
2026-09-12 16:06:16 +08:00
parent 418f11f80e
commit d29713aeda
21 changed files with 973 additions and 7 deletions
@@ -140,6 +140,28 @@ class ChatTranscriptTest {
list.layoutInfo.visibleItemsInfo.first { it.key != "chat-history-control" }.let { it.key to it.offset }
}
@Test fun planUpdatesAndRecyclingPreserveTheReadingAnchor() {
fun plan(text: String) = previewToolCall("proposal", "ExitPlanMode", input = mapOf("plan" to text))
val initial = "# Visible proposal\n\nRead the plan without tapping a tool."
markdown.prepare(setOf(initial))
mount(rows(0..19) + plan(initial) + rows(21..79))
browse(21, 0)
compose.onNodeWithText("Visible proposal").assertIsDisplayed()
val before = anchor()
val updated = "# Revised proposal\n\n" + "More plan detail. ".repeat(100)
markdown.prepare(setOf(updated))
compose.runOnIdle {
state.value = state.value.copy(blocks = rows(0..19) + plan(updated) + rows(21..79), messagesVersion = 1)
}
compose.waitForIdle()
compose.onNodeWithText("Revised proposal").assertIsDisplayed()
assertEquals(before, anchor())
browse(60, 0)
browse(21, 0)
compose.onNodeWithText("Revised proposal").assertIsDisplayed()
compose.onNodeWithText("Visible proposal").assertDoesNotExist()
}
@Test fun tailStillFollowsTallRowGrowthAppendsAndViewportResize() {
mount(rows(0..39))
fun assertFollowing() {
@@ -0,0 +1,130 @@
package app.hapi.companion.feature.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import app.hapi.companion.feature.chat.blocks.ToolCallBlockView
import app.hapi.companion.feature.chat.blocks.previewToolCall
import app.hapi.companion.ui.theme.HapiTheme
import app.hapi.protocol.chat.ToolPermission
import app.hapi.protocol.chat.ChatToolCall
import app.hapi.protocol.chat.ToolCallBlock
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class PlanProposalTest {
@get:Rule val compose = createComposeRule()
private fun ToolCallBlock.withTool(value: ChatToolCall) = ToolCallBlock(
id = id, localId = localId, createdAt = createdAt, invokedAt = invokedAt,
tool = value, children = children, meta = meta,
)
@Test fun plansStartExpandedUpdateInPlaceAndKeepSourceAndErrors() {
val block = mutableStateOf(previewToolCall("proposal", "ExitPlanMode", input = mapOf("plan" to "# 实施计划\n\nRead **input.plan**.")))
block.value = block.value.withTool(block.value.tool.copy(result = JsonNull))
compose.setContent {
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
}
compose.onNodeWithText("实施计划").assertIsDisplayed()
compose.onNodeWithText("Read input.plan.").assertIsDisplayed()
compose.onNodeWithText("Input").assertDoesNotExist()
compose.onNodeWithText("Result").assertDoesNotExist()
compose.onNodeWithText("Awaiting approval").assertDoesNotExist()
compose.onNodeWithText("Plan proposal").performClick()
compose.onNodeWithText("实施计划").assertDoesNotExist()
// The same call keeps the user's explicit folding through updates.
compose.runOnIdle {
block.value = block.value.withTool(block.value.tool.copy(input = JsonObject(mapOf("plan" to JsonPrimitive("# Revised plan\n\nUpdated document.")))))
}
compose.onNodeWithText("Revised plan").assertDoesNotExist()
compose.onNodeWithText("Plan proposal").performClick()
compose.onNodeWithText("Revised plan").assertIsDisplayed()
compose.onNodeWithText("实施计划").assertDoesNotExist()
compose.onNodeWithText("Source").performClick()
compose.waitUntil(10_000) { compose.onAllNodesWithText("\"plan\"", substring = true).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText("Input").assertIsDisplayed()
compose.onNodeWithText("Source").performClick()
compose.runOnIdle {
block.value = block.value.withTool(block.value.tool.copy(state = "error", result = JsonPrimitive("Plan failed to apply")))
}
compose.waitUntil(10_000) { compose.onAllNodesWithText("Plan failed to apply", substring = true).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText("Plan failed to apply", substring = true).performScrollTo().assertIsDisplayed()
}
@Test fun lowercaseProposalOpensWhenInputArrivesAndPrecedesApproval() {
val block = mutableStateOf(previewToolCall("proposal", "unknown"))
compose.setContent {
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
}
compose.runOnIdle {
block.value = previewToolCall("proposal", "exit_plan_mode", input = mapOf("plan" to "## Review first\n\nThen approve."),
permission = ToolPermission(id = "approval", status = "pending", presence = emptySet()))
}
compose.onNodeWithText("Review first").assertIsDisplayed()
val document = compose.onNodeWithText("Then approve.").fetchSemanticsNode().boundsInRoot
val approval = compose.onNodeWithText("⏳ Awaiting approval").fetchSemanticsNode().boundsInRoot
assertTrue("Read the plan before approval", document.bottom <= approval.top)
}
@Test fun longPlansRemainFullMarkdownInsteadOfPagedToolSource() {
val source = "# Long plan\n\n" + "A complete paragraph 中文. ".repeat(1_000) + "\n\n## Last heading"
assertTrue(source.length > 20_000)
val block = previewToolCall("long-plan", "exit_plan_mode", input = mapOf("plan" to source))
compose.setContent {
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block, null) } }
}
compose.onNodeWithText("Long plan").assertIsDisplayed()
compose.onNodeWithText("Last heading").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Load more content").assertDoesNotExist()
compose.onNodeWithText("Source").assertExists()
}
@Test fun ordinaryToolsRemainCollapsedAndMalformedPlanInputStaysAccessible() {
val block = mutableStateOf(previewToolCall("ordinary", "Bash", input = mapOf("command" to "echo hidden")))
compose.setContent {
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
}
compose.onNodeWithText("Source").assertDoesNotExist()
compose.runOnIdle { block.value = previewToolCall("malformed", "ExitPlanMode", input = mapOf("unexpected" to "retained")) }
compose.waitUntil(10_000) { compose.onAllNodesWithText("retained", substring = true).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText("retained", substring = true).assertIsDisplayed()
}
@Test fun plansReflowAcrossThemeAndFontScaleChanges() {
val dark = mutableStateOf(false)
val scale = mutableStateOf(1f)
val plan = "# 实施计划\n\n- Inspect **input.plan**\n\n| Client | Ready |\n| --- | --- |\n| Android | Yes |\n\n```kotlin\nval ready = true\n```\n\n[Documentation](https://hapi.run)\n\nEnd of plan"
val block = previewToolCall("theme-plan", "ExitPlanMode", input = mapOf("plan" to plan))
compose.setContent {
HapiTheme(darkTheme = dark.value, dynamicColor = false) {
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = scale.value)) {
Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block, null) }
}
}
}
compose.onNodeWithText("实施计划").assertIsDisplayed()
val normalHeight = compose.onNodeWithText("End of plan").fetchSemanticsNode().boundsInRoot.height
compose.runOnIdle { dark.value = true }
compose.onNodeWithText("End of plan").performScrollTo().assertIsDisplayed()
compose.runOnIdle { scale.value = 2f }
compose.onNodeWithText("实施计划").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("End of plan").performScrollTo().assertIsDisplayed()
assertTrue(compose.onNodeWithText("End of plan").fetchSemanticsNode().boundsInRoot.height > normalHeight)
}
}
@@ -2,6 +2,7 @@ package app.hapi.companion.feature.chat
import androidx.annotation.MainThread
import app.hapi.companion.feature.chat.attachments.ComposerAttachments
import app.hapi.companion.feature.chat.blocks.planProposalMarkdown
import app.hapi.companion.feature.chat.composer.ChatDrafts
import app.hapi.companion.feature.chat.composer.SlashCommands
import app.hapi.companion.feature.chat.composer.appendTranscript
@@ -31,6 +32,7 @@ import app.hapi.protocol.catalog.PermissionMode
import app.hapi.protocol.catalog.PermissionModes
import app.hapi.protocol.chat.NormalizedMessage
import app.hapi.protocol.chat.ToolGroupBlock
import app.hapi.protocol.chat.ToolCallBlock
import app.hapi.protocol.chat.ToolGroupingOptions
import app.hapi.protocol.chat.VisibleChatBlock
import app.hapi.protocol.chat.buildVisibleChatBlocks
@@ -1671,6 +1673,7 @@ class ChatViewModel(
when (block) {
is AgentTextBlock -> block.text
is AgentReasoningBlock -> block.text
is ToolCallBlock -> planProposalMarkdown(block.tool)
else -> null
}
}.toSet()
@@ -46,16 +46,22 @@ import kotlinx.serialization.json.JsonPrimitive
* - `Write` → the written content as a code block;
* - `CodexDiff` (and any input/result that parses as a unified diff) → [DiffView];
* - `TodoWrite`/`update_plan` → checklist rows;
* - `ExitPlanMode`/`exit_plan_mode` → complete Markdown proposal from input;
* - Ask/RequestUserInput → questions + selected answers, read-only;
* - anything else → pretty-printed JSON input, then the generic result.
*/
@Composable
internal fun ToolCallBody(tool: ChatToolCall, basePath: String?, modifier: Modifier = Modifier) {
val questionTool = isQuestionDetailsTool(tool.name)
val plan = planProposalMarkdown(tool)
val answers = if (questionTool) tool.permission?.answers else null
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (questionTool) {
QuestionToolBody(tool)
} else if (plan != null) {
// Plans are reading documents, never paged/truncated tool source.
Markdown(text = plan)
if (planProposalShowsResult(tool)) ToolResultSection(tool)
} else {
SectionLabel(stringResource(R.string.settings_usage_input))
ToolInputSection(tool)
@@ -51,8 +51,8 @@ import kotlinx.serialization.json.JsonPrimitive
* One tool invocation (web `ToolCard`): collapsed header row — icon glyph,
* title, subtitle, status — expanding to the per-tool body ([ToolCallBody]),
* the read-only permission state, and nested children (sidechain transcript).
* Cards with a pending permission start expanded and carry the
* "awaiting approval" banner (actions land in M3b).
* Plan proposals and pending permissions start expanded. Only actual pending
* permissions carry the "awaiting approval" banner.
*/
@Composable
fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifier = Modifier) {
@@ -60,7 +60,10 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
val resources = LocalContext.current.resources
val presentation = remember(tool, basePath, resources) { toolCardPresentation(tool, basePath, resources) }
val pendingPermission = tool.permission?.status == "pending"
var expanded by rememberSaveable(block.id) { mutableStateOf(pendingPermission) }
val planProposal = isPlanProposalTool(tool.name)
// An output-first placeholder can acquire its real tool name later. Open a
// newly recognized plan, but retain explicit folding across input updates.
var expanded by rememberSaveable(block.id, planProposal) { mutableStateOf(pendingPermission || planProposal) }
val colors = MaterialTheme.hapi
Surface(
@@ -99,6 +102,14 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
ToolStatusIndicator(tool.state)
}
if (planProposal && expanded) {
ToolCallBody(
tool = tool,
basePath = basePath,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
)
}
tool.permission?.let { permission ->
val interactions = LocalChatInteractions.current
if (permission.status == "pending" && interactions != null) {
@@ -126,7 +137,7 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
}
}
if (expanded) {
if (!planProposal && expanded) {
ToolCallBody(
tool = tool,
basePath = basePath,
@@ -22,6 +22,20 @@ internal fun toolSourceInput(input: JsonElement?, keys: List<String>): String? =
private fun JsonElement?.textValue(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
internal fun isPlanProposalTool(name: String): Boolean = name == "ExitPlanMode" || name == "exit_plan_mode"
/** A proposal is input.plan Markdown, not update_plan's checklist or a result. */
internal fun planProposalMarkdown(tool: ChatToolCall): String? =
if (isPlanProposalTool(tool.name)) (tool.input as? JsonObject)?.get("plan").textValue()?.takeIf { it.isNotBlank() }
else null
internal fun planProposalShowsResult(tool: ChatToolCall): Boolean {
if (tool.state == "error") return true
val result = tool.result ?: return false
if (result is JsonNull) return false
return result.textValue()?.isNotBlank() ?: true
}
/** Mixed content stays JSON, rather than silently discarding images/resources. */
internal fun extractResultText(result: JsonElement, depth: Int = 0): String? {
if (depth > 4) return null
@@ -649,6 +649,11 @@ class ChatViewModelTest {
blocks.filterIsInstance<ToolCallBlock>().any { it.tool.name == "Bash" }
},
"claude-assistant-text.json" to { blocks -> blocks.any { it is AgentTextBlock } },
"codex-plan-proposal-completed.json" to { blocks ->
blocks.filterIsInstance<ToolCallBlock>().any {
app.hapi.companion.feature.chat.blocks.planProposalMarkdown(it.tool) != null
}
},
)
for ((name, expectation) in fixtures) {
@@ -673,6 +678,11 @@ class ChatViewModelTest {
harness.viewModel.start()
val state = harness.viewModel.uiState.first { it.blocks.isNotEmpty() }
assertTrue(expectation(state.blocks), "fixture $name should satisfy its block expectation")
for (block in state.blocks.filterIsInstance<ToolCallBlock>()) {
app.hapi.companion.feature.chat.blocks.planProposalMarkdown(block.tool)?.let { plan ->
assertTrue(harness.viewModel.markdownCache.cached(plan) != null, "Plans must be prepared before publication")
}
}
harness.viewModel.stop()
}
}
@@ -6,8 +6,10 @@ import app.hapi.protocol.wire.HapiJson
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -30,6 +32,33 @@ class ToolContentPresentationTest {
assertNull(toolSourceInput(json("""{"unknown":true}"""), listOf("patch")))
}
@Test fun planProposalsReadInputAndPreserveTheCompleteDocument() {
val plan = "\n# 计划 👩🏽‍💻\n\n" + "- Inspect **input.plan** \n".repeat(1_000) + "last \n"
for (name in listOf("ExitPlanMode", "exit_plan_mode")) {
val input = JsonObject(mapOf("plan" to JsonPrimitive(plan), "extra" to JsonPrimitive(true)))
val call = tool(name, input, JsonNull)
assertTrue(isPlanProposalTool(name))
assertEquals(plan, planProposalMarkdown(call))
assertEquals(input, call.input)
assertFalse(planProposalShowsResult(call))
for (result in listOf(JsonPrimitive("Approved"), json("""{"error":"Failed"}"""))) {
assertTrue(planProposalShowsResult(tool(name, input, result)))
}
for (result in listOf(null, JsonNull, JsonPrimitive(" \n"))) {
assertFalse(planProposalShowsResult(tool(name, input, result)))
}
assertTrue(planProposalShowsResult(tool(name, input, state = "error")))
for (value in listOf("null", "[]", "{}", """{"plan":null}""", """{"plan":42}""",
"""{"plan":[]}""", """{"plan":" \n"}""", """"a raw string"""")) {
assertNull(planProposalMarkdown(tool(name, json(value))), value)
}
}
val input = JsonObject(mapOf("plan" to JsonPrimitive(plan)))
assertNull(planProposalMarkdown(tool("update_plan", input)))
assertNull(planProposalMarkdown(tool("mcp__server__ExitPlanMode", input)))
assertNull(planProposalMarkdown(tool("update_plan", json("""{"plan":[{"step":"Inspect","status":"completed"}]}"""))))
}
@Test fun resultEnvelopesAndWhitespace() {
for (payload in listOf("""{"output":"hello\n"}""", """{"result":{"data":{"text":"hello\n"}}}""",
"""{"content":["hello\n"]}""", """{"file":{"content":"hello\n"}}""",