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
@@ -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