mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-04 00:01:41 +00:00
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:
@@ -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)
|
||||
|
||||
+15
-4
@@ -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,
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user