mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +00:00
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions. Include native regression tests and localized plan controls.
242 lines
9.3 KiB
Swift
242 lines
9.3 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 interactions {
|
|
CodexPlanActionsView(planId: block.tool.id, interactions: interactions)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|