Files
hapi/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift
T
weishu 6c0ef32350 feat(ios): add native tool inspector and compact tool groups
Move tool output into a live sheet with group navigation and full-content copying. Keep lightweight grouped summaries inline, add agent process pages, and preserve transcript reading position.\n\nAdd real transcript and sheet regression coverage with localized summaries.
2026-09-11 11:30:13 +08:00

160 lines
6.9 KiB
Swift

import HapiProtocol
import HapiUI
import SwiftUI
/// A short activity count, with secondary category counts. Inline and recycled
/// transcript rows share the same segments so an expanded group stays connected.
struct ToolGroupBlockView: View {
let block: ToolGroupBlock
let basePath: String?
@State private var expanded: Bool
private let expansion: Binding<Bool>?
private let showsTools: Bool
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
init(block: ToolGroupBlock, basePath: String?, expansion: Binding<Bool>? = nil, showsTools: Bool = true) {
self.block = block
self.basePath = basePath
_expanded = State(initialValue: block.defaultOpen)
self.expansion = expansion
self.showsTools = showsTools
}
var body: some View {
let isExpanded = expansion?.wrappedValue ?? expanded
VStack(alignment: .leading, spacing: 0) {
headerRow
.modifier(ToolGroupSurface(isFirst: true, isLast: !isExpanded || block.tools.isEmpty))
if showsTools && isExpanded {
ForEach(block.tools, id: \.id) { tool in
ToolGroupChildRow(block: tool, basePath: basePath, isLast: tool.id == block.tools.last?.id)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var headerRow: some View {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
if let expansion { expansion.wrappedValue.toggle() }
else { expanded.toggle() }
}
} label: {
let layout = typography.usesStackedToolLayout
? AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
: AnyLayout(HStackLayout(spacing: 8))
layout {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(typography.toolSubtitleFont)
.foregroundStyle(theme.textSecondary)
.frame(width: typography.toolIconWidth)
VStack(alignment: .leading, spacing: 4) {
Text(toolGroupTitle(block))
.font(typography.toolTitleFont)
.foregroundStyle(theme.textPrimary)
.lineLimit(typography.usesStackedToolLayout ? 2 : 1)
.fixedSize(horizontal: false, vertical: true)
if !categorySummary.isEmpty {
Text(categorySummary)
.font(typography.toolSubtitleFont)
.foregroundStyle(theme.textSecondary)
.lineLimit(typography.usesStackedToolLayout ? 2 : 1)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !typography.usesStackedToolLayout { Spacer(minLength: 8) }
trailingIndicator
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue((expansion?.wrappedValue ?? expanded) ? String(localized: "Expanded") : String(localized: "Collapsed"))
.accessibilityIdentifier("tool-group-\(block.id)")
}
private var categorySummary: String {
let parts = ToolGroupActionKind.allCases.compactMap { kind -> String? in
let count = block.summary.countsByKind[kind] ?? 0
guard count > 0 else { return nil }
let format: String
switch kind {
case .read: format = count == 1 ? String(localized: "1 read") : String(localized: "%lld reads")
case .search: format = count == 1 ? String(localized: "1 search") : String(localized: "%lld searches")
case .command: format = count == 1 ? String(localized: "1 command") : String(localized: "%lld commands")
case .mutation: format = count == 1 ? String(localized: "1 edit") : String(localized: "%lld edits")
case .web: format = count == 1 ? String(localized: "1 web request") : String(localized: "%lld web requests")
case .other: format = count == 1 ? String(localized: "1 other tool") : String(localized: "%lld other tools")
}
return String(format: format, Int64(count))
}
return parts.joined(separator: " · ")
}
private var trailingIndicator: some View {
HStack(spacing: 8) {
if block.summary.runningCount > 0 {
ProgressView().controlSize(.small)
.accessibilityLabel("Running")
}
if block.summary.errorCount > 0 {
Label("\(block.summary.errorCount)", systemImage: "exclamationmark.circle")
.font(typography.toolSubtitleFont).foregroundStyle(.red)
.accessibilityLabel(String(format: String(localized: "%lld failed tools"), Int64(block.summary.errorCount)))
}
Image(systemName: (expansion?.wrappedValue ?? expanded) ? "chevron.down" : "chevron.right")
.font(typography.captionFont).foregroundStyle(theme.textSecondary)
}
}
}
func toolGroupTitle(_ block: ToolGroupBlock) -> String {
let count = block.summary.totalTools
let title = count == 1 ? String(localized: "1 tool call")
: String(format: String(localized: "%lld tool calls"), Int64(count))
return block.activityTitle.map { "\($0) · \(title)" } ?? title
}
struct ToolGroupChildRow: View {
let block: ToolCallBlock
let basePath: String?
let isLast: Bool
var body: some View {
ToolCallBlockView(block: block, basePath: basePath, compact: true)
.modifier(ToolGroupSurface(isFirst: false, isLast: isLast))
}
}
/// Corner ownership follows the group's live boundaries, not a recycled cell's
/// identity. No inter-row padding: the virtualized rows form one continuous card.
private struct ToolGroupSurface: ViewModifier {
let isFirst: Bool
let isLast: Bool
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
func body(content: Content) -> some View {
content
.background(theme.surface, in: UnevenRoundedRectangle(
topLeadingRadius: isFirst ? 12 : 0,
bottomLeadingRadius: isLast ? 12 : 0,
bottomTrailingRadius: isLast ? 12 : 0,
topTrailingRadius: isFirst ? 12 : 0
))
.overlay(alignment: .top) {
if !isFirst {
Rectangle().fill(theme.divider).frame(height: 0.5)
.padding(.leading, 12 + typography.toolIconWidth + 8).padding(.trailing, 12)
}
}
}
}