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) } } }