mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
234 lines
8.9 KiB
Swift
234 lines
8.9 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 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 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)
|
|
}
|
|
}
|