Files
hapi/ios/Hapi/Features/Chat/ToolDetailView.swift
T

396 lines
17 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
import SwiftUI
import UIKit
/// Installed on screens, never on transcript cells. Each visible screen has
/// one presenter; all presenters share the chat's single live inspector.
private struct ToolPresentationHost: ViewModifier {
let model: ChatModel
let session: HubSession
let owner: String
let openFile: (String) -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var processRoute: ToolProcessRoute?
@State private var pendingFile: String?
@State private var pendingProcess: ToolCallBlock?
func body(content: Content) -> some View {
// Read in the host's observation scope, not solely inside Binding's
// escaping getter (which SwiftUI may evaluate in the sheet's scope).
let inspectorPresented = model.toolInspection.owner == owner
content
.environment(\.openChatTool, { block in
model.beginContentInspection()
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
if opensToolProcess(block) {
// Acquire before navigation hides the parent. The child's
// appearance takes over this same idempotent lease.
model.retainSurface("process:\(block.id)")
processRoute = ToolProcessRoute(block: block)
} else {
model.retainSurface("inspector:\(owner)")
model.toolInspection.open(block, owner: owner)
}
})
.environment(\.openChatToolGroup, { id in
guard model.inspectToolGroup(id, owner: owner) else { return }
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
})
.sheet(isPresented: Binding(
get: { inspectorPresented },
set: { if !$0 { model.toolInspection.dismiss(owner: owner) } }
), onDismiss: {
model.toolInspection.dismiss(owner: owner)
if let pendingProcess {
model.retainSurface("process:\(pendingProcess.id)")
processRoute = ToolProcessRoute(block: pendingProcess)
self.pendingProcess = nil
}
model.releaseSurface("inspector:\(owner)")
if let pendingFile {
openFile(pendingFile)
self.pendingFile = nil
}
}) {
ToolInspectionSheet(inspection: model.toolInspection, basePath: model.basePath,
isReconnecting: model.isReconnecting, openProcess: { block in
pendingProcess = block
model.toolInspection.dismiss(owner: owner)
}) { path in
pendingFile = path
model.toolInspection.dismiss(owner: owner)
}
.environment(\.dynamicTypeSize, dynamicTypeSize)
}
.navigationDestination(item: $processRoute) { route in
ToolProcessView(model: model, session: session, initialBlock: route.block)
.id(route.id)
}
.onChange(of: processRoute?.id) { old, new in
if let old, old != new { model.releaseSurface("process:\(old)") }
}
.onChange(of: model.toolInspection.invalidation) {
pendingFile = nil
pendingProcess = nil
processRoute = nil
}
.onAppear { model.retainSurface(owner) }
.onDisappear {
if owner == "chat" { model.dictation.cancel() }
model.releaseSurface(owner)
}
}
}
extension View {
func toolPresentations(model: ChatModel, session: HubSession, owner: String,
openFile: @escaping (String) -> Void) -> some View {
modifier(ToolPresentationHost(model: model, session: session, owner: owner, openFile: openFile))
.modifier(MessagePresentationHost(model: model, owner: owner))
}
}
private struct ToolProcessRoute: Identifiable, Hashable {
let block: ToolCallBlock
var id: String { block.id }
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
struct ToolInspectionSheet: View {
let inspection: ToolInspectionState
let basePath: String?
var isReconnecting = false
let openProcess: ((ToolCallBlock) -> Void)?
let openFile: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var lastGroup: ToolGroupBlock?
@State private var showsGroupTool = false
init(inspection: ToolInspectionState, basePath: String?, isReconnecting: Bool = false,
openProcess: ((ToolCallBlock) -> Void)? = nil,
openFile: @escaping (String) -> Void) {
self.inspection = inspection
self.basePath = basePath
self.isReconnecting = isReconnecting
self.openProcess = openProcess
self.openFile = openFile
_lastGroup = State(initialValue: inspection.groupSelection?.block)
_showsGroupTool = State(initialValue: inspection.groupSelection != nil && inspection.selection != nil)
}
var body: some View {
NavigationStack {
if let group = inspection.groupSelection?.block ?? lastGroup {
ToolGroupBrowser(group: group, basePath: basePath, isStale: inspection.isGroupStale,
isReconnecting: isReconnecting,
selectTool: { id in
if let block = inspection.groupTool(id), opensToolProcess(block), let openProcess {
openProcess(block)
} else {
inspection.selectGroupTool(id)
}
}, close: { dismiss() })
.navigationDestination(isPresented: $showsGroupTool) {
detail(isInGroup: true)
}
} else {
detail(isInGroup: false)
}
}
.hapiTypography()
.onChange(of: inspection.groupSelection?.block) { _, latest in
if let latest { lastGroup = latest }
}
.onChange(of: inspection.selection?.block.id) { _, id in
// Do not pop the detail during the sheet's dismissal animation.
if inspection.groupSelection != nil { showsGroupTool = id != nil }
}
.onChange(of: showsGroupTool) { _, shown in
if !shown { inspection.returnToGroup() }
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
private func detail(isInGroup: Bool) -> some View {
ToolInspectorDetail(inspection: inspection, basePath: basePath, isReconnecting: isReconnecting,
isInGroup: isInGroup, openFile: openFile, close: { dismiss() })
}
}
private struct ToolInspectorDetail: View {
let inspection: ToolInspectionState
let basePath: String?
let isReconnecting: Bool
let isInGroup: Bool
let openFile: (String) -> Void
let close: () -> Void
@State private var lastBlock: ToolCallBlock?
init(inspection: ToolInspectionState, basePath: String?, isReconnecting: Bool,
isInGroup: Bool, openFile: @escaping (String) -> Void, close: @escaping () -> Void) {
self.inspection = inspection
self.basePath = basePath
self.isReconnecting = isReconnecting
self.isInGroup = isInGroup
self.openFile = openFile
self.close = close
_lastBlock = State(initialValue: inspection.selection?.block)
}
var body: some View {
Group {
if let block = inspection.selection?.block ?? lastBlock {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if inspection.isStale {
ToolSnapshotNotice()
} else if isReconnecting {
Label("Live updates interrupted — reconnecting…", systemImage: "wifi.slash")
.font(.footnote).foregroundStyle(.secondary)
}
ToolDetailHeader(block: block, basePath: basePath, openFile: openFile)
ToolCallBody(tool: block.tool, basePath: basePath)
}
.hapiReadingColumn()
.padding(.vertical, 16)
}
// Only explicit selection changes reset the scroll position.
.id(block.id)
.navigationTitle("Tool details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolCopyMenu(tool: block.tool)
}
ToolbarItem(placement: isInGroup ? .topBarTrailing : .cancellationAction) {
Button("Close", systemImage: "xmark", action: close)
.accessibilityIdentifier("tool-inspector-close")
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if inspection.siblingIDs.count > 1 { siblingNavigation }
}
}
}
.onChange(of: inspection.selection?.block) { _, latest in
if let latest { lastBlock = latest }
}
}
private var siblingNavigation: some View {
HStack {
Button { inspection.move(by: -1) } label: {
Label("Previous tool", systemImage: "chevron.left")
.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
}
.disabled((inspection.selectedIndex ?? 0) == 0)
Spacer()
Text(verbatim: "\((inspection.selectedIndex ?? 0) + 1) / \(inspection.siblingIDs.count)")
.font(.footnote.monospacedDigit())
.accessibilityLabel(String(format: String(localized: "Tool %lld of %lld"),
Int64((inspection.selectedIndex ?? 0) + 1), Int64(inspection.siblingIDs.count)))
Spacer()
Button { inspection.move(by: 1) } label: {
Label("Next tool", systemImage: "chevron.right")
.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
}
.disabled((inspection.selectedIndex ?? 0) >= inspection.siblingIDs.count - 1)
}
.hapiReadingColumn()
.padding(.vertical, 4)
.background(.bar)
}
}
private struct ToolDetailHeader: View {
let block: ToolCallBlock
let basePath: String?
var openFile: ((String) -> Void)?
var permissionActionsInline = false
var body: some View {
let presentation = toolCardPresentation(block.tool, basePath: basePath)
VStack(alignment: .leading, spacing: 10) {
Label(presentation.title, systemImage: presentation.icon)
.font(.headline).textSelection(.enabled)
if isQuestionDetailsTool(block.tool.name) {
let state = QuestionCardState(tool: block.tool, details: questionToolDetails(block.tool), override: nil)
Label(state.title, systemImage: state.icon)
.font(.footnote)
.foregroundStyle(state == .failed ? Color.red : .secondary)
} else {
HStack(spacing: 8) {
ToolStatusIndicator(state: block.tool.state)
Text(statusLabel).font(.footnote).foregroundStyle(.secondary)
}
if let permission = block.tool.permission {
PermissionStateRow(permission: permission)
}
}
if block.tool.permission?.status == .pending && !permissionActionsInline {
Text("Close details to approve or answer in the conversation.")
.font(.footnote).foregroundStyle(.secondary)
}
if let path = toolFilePath(block.tool) {
Text(path).font(.footnote.monospaced()).textSelection(.enabled)
if let openFile {
Button { openFile(path) } label: {
Label("View current file", systemImage: "doc.text.magnifyingglass")
.frame(minHeight: 44)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var statusLabel: String {
switch block.tool.state {
case .pending: String(localized: "pending")
case .running: String(localized: "Running")
case .completed: String(localized: "Completed")
case .error: String(localized: "error")
}
}
}
private struct ToolCopyMenu: View {
let tool: ChatToolCall
@Environment(\.hapiPasteboard) private var pasteboard
var body: some View {
Menu {
if let input = tool.input {
Button("Copy input") { pasteboard.copy(input.chatString ?? chatPrettyJSON(input)) }
}
if let result = tool.result {
Button("Copy result") { pasteboard.copy(result.chatString ?? chatPrettyJSON(result)) }
}
if let path = toolFilePath(tool) {
Button("Copy Path") { pasteboard.copy(path) }
}
} label: {
Label("Copy", systemImage: "doc.on.doc")
.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
}
}
}
func toolFilePath(_ tool: ChatToolCall) -> String? {
if let value = chatInputString(tool.input, ["file_path", "file", "filePath", "notebook_path"]), !value.isEmpty {
return value
}
// Generic `path` often identifies a directory (LS, search, MCP). Do not
// offer a file viewer unless this is a known file operation.
guard ["Read", "Write", "Edit", "MultiEdit", "NotebookRead", "NotebookEdit"].contains(toolPresentationName(tool.name)),
let path = chatInputString(tool.input, ["path"]), !path.isEmpty else { return nil }
return path
}
private struct ToolSnapshotNotice: View {
var body: some View {
Label("Showing the last available record. Live updates are unavailable for this tool.", systemImage: "clock.arrow.circlepath")
.font(.footnote).foregroundStyle(.secondary)
}
}
private struct ToolProcessView: View {
let model: ChatModel
let session: HubSession
@State private var snapshot: ToolCallBlock
@State private var fileRoute: FileViewerRoute?
init(model: ChatModel, session: HubSession, initialBlock: ToolCallBlock) {
self.model = model
self.session = session
_snapshot = State(initialValue: initialBlock)
}
private var block: ToolCallBlock { model.toolInspection.tools[snapshot.id] ?? snapshot }
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if model.toolInspection.tools[snapshot.id] == nil { ToolSnapshotNotice() }
ToolDetailHeader(block: block, basePath: model.basePath, openFile: { path in
fileRoute = FileViewerRoute(sessionId: model.sessionId, path: path, mode: .file)
}, permissionActionsInline: true)
if model.toolInspection.tools[snapshot.id] != nil,
let permission = block.tool.permission, permission.status == .pending {
PendingPermissionFooter(tool: block.tool, requestId: permission.id, interactions: model.interactor)
}
ToolCallBody(tool: block.tool, basePath: model.basePath)
Text("Agent process").font(.headline)
if block.children.isEmpty {
Text("No agent steps available yet.").font(.footnote).foregroundStyle(.secondary)
}
ForEach(block.children, id: \.id) { child in
ChatSubBlockView(block: child, basePath: model.basePath)
}
}
.hapiReadingColumn()
.padding(.vertical, 16)
}
.navigationTitle("Agent process")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) { ToolCopyMenu(tool: block.tool) }
}
.navigationDestination(item: $fileRoute) { route in
FileViewerView(session: session, route: route).id(route)
}
.onChange(of: model.toolInspection.tools[snapshot.id]) { _, latest in
if let latest { snapshot = latest }
}
.hapiTypography()
.environment(\.chatInteractions, model.toolInspection.tools[snapshot.id] == nil ? nil : model.interactor)
.toolPresentations(model: model, session: session, owner: "process:\(snapshot.id)") { path in
fileRoute = FileViewerRoute(sessionId: model.sessionId, path: path, mode: .file)
}
}
}