Files
hapi/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift
T
weishu d29713aeda 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.
2026-09-12 16:06:16 +08:00

239 lines
9.1 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
import SwiftUI
/// A bounded activity summary. Ordinary tools open the shared inspector;
/// sidechains open their own transcript. Questions, plans and approvals stay inline.
struct ToolCallBlockView: View {
let block: ToolCallBlock
let basePath: String?
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
@Environment(\.chatInteractions) private var interactions
@Environment(\.openChatTool) private var openTool
var body: some View {
if isQuestionDetailsTool(block.tool.name) {
QuestionToolCard(block: block)
} else {
activityCard
}
}
@ViewBuilder
private var activityCard: some View {
let presentation = toolSummaryPresentation(block.tool, basePath: basePath)
VStack(alignment: .leading, spacing: 0) {
headerRow(presentation)
if let plan = planProposalMarkdown(block.tool) {
PlanProposalContent(markdown: plan)
.padding(12)
.accessibilityIdentifier("plan-proposal-\(block.id)")
}
if let permission = block.tool.permission {
if permission.status == .pending, let interactions {
pendingApprovalSection(permission: permission, interactions: interactions)
} else {
PermissionStateRow(permission: permission)
}
}
if opensToolProcess(block) {
Button { openTool?(block) } label: {
Label(
String(format: String(localized: "View process · %lld steps"), Int64(block.children.count)),
systemImage: "arrow.right"
)
.font(typography.captionFont)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(theme.textSecondary)
.padding(.horizontal, 10)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
/// Pending permission with a live interaction engine: highlighted banner
/// plus the actionable footer (approve buttons / answer forms).
private func pendingApprovalSection(
permission: ToolPermission,
interactions: ChatInteractor
) -> some View {
VStack(alignment: .leading, spacing: 0) {
Label("Awaiting approval", systemImage: "hourglass")
.font(.footnote.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.top, 6)
Button { openTool?(block) } label: {
Label("View full input", systemImage: "arrow.up.right.square")
.font(typography.toolSubtitleFont)
.frame(minHeight: 44)
}
.padding(.horizontal, 10)
PermissionActionsRow(
tool: block.tool,
requestId: permission.id,
interactions: interactions
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.10))
}
private func headerRow(_ presentation: ToolCardPresentation) -> some View {
ToolSummaryRow(presentation: presentation, state: block.tool.state) { openTool?(block) }
.accessibilityHint(opensToolProcess(block)
? String(localized: "View agent process") : String(localized: "View tool details"))
.accessibilityIdentifier("tool-summary-\(block.id)")
}
}
/// Lightweight, read-only row shared by the conversation and group browser.
/// Tool output and approval controls are deliberately not part of this view.
struct ToolSummaryRow: View {
let presentation: ToolCardPresentation
let state: ToolCallState
let action: () -> Void
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
var body: some View {
Button(action: action) {
let layout = typography.usesStackedToolLayout
? AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
: AnyLayout(HStackLayout(spacing: 8))
layout {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: presentation.icon)
.font(typography.toolSubtitleFont)
.foregroundStyle(theme.textSecondary)
.frame(width: typography.toolIconWidth)
VStack(alignment: .leading, spacing: 3) {
Text(presentation.title)
.font(typography.toolTitleFont)
.foregroundStyle(theme.textPrimary)
.lineLimit(typography.usesStackedToolLayout ? 2 : 1)
.truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
if let subtitle = presentation.subtitle {
Text(subtitle)
.font(typography.toolSubtitleFont)
.foregroundStyle(theme.textSecondary)
// Full commands belong in the inspector, not above approval controls.
.lineLimit(typography.usesStackedToolLayout ? 2 : 1)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !typography.usesStackedToolLayout { Spacer(minLength: 8) }
HStack(spacing: 8) {
if state != .completed {
ToolStatusIndicator(state: state)
}
Image(systemName: "chevron.right")
.font(typography.captionFont)
.foregroundStyle(theme.textSecondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue(state == .completed ? String(localized: "Completed") : "")
}
}
// MARK: - Status
struct ToolStatusIndicator: View {
let state: ToolCallState
var body: some View {
switch state {
case .running:
ProgressView()
.controlSize(.small)
.accessibilityLabel("Running")
case .pending:
StatusChip(text: String(localized: "pending"), tint: .secondary)
case .error:
StatusChip(text: String(localized: "error"), tint: .red)
case .completed:
Image(systemName: "checkmark")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Completed")
}
}
}
private struct StatusChip: View {
let text: String
let tint: Color
var body: some View {
Text(text)
.font(.footnote)
.foregroundStyle(tint)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 6))
}
}
// MARK: - Permission (read-only)
/// Read-only permission verdict: highlighted banner while pending (shown only
/// without a `\.chatInteractions` engine — previews/tests; the live chat
/// renders `PendingPermissionFooter` instead), subdued line once decided.
struct PermissionStateRow: View {
let permission: ToolPermission
var body: some View {
switch permission.status {
case .pending:
Label("Awaiting approval", systemImage: "hourglass")
.font(.footnote.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.orange.opacity(0.18))
.foregroundStyle(.orange)
case .approved:
PermissionLine(text: String(localized: "✓ Approved") + (permission.mode.map { " · \($0)" } ?? ""))
case .denied:
PermissionLine(
text: String(localized: "✕ Denied") + (permission.reason.map { " · \($0)" } ?? ""),
isError: true
)
case .resolved:
PermissionLine(text: String(localized: "Resolved in Codex"))
case .canceled:
PermissionLine(text: String(localized: "— Canceled"))
}
}
}
private struct PermissionLine: View {
let text: String
var isError = false
var body: some View {
Text(text)
.font(.footnote)
.foregroundStyle(isError ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
.padding(.horizontal, 10)
.padding(.bottom, 6)
}
}