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

176 lines
6.0 KiB
Swift

import HapiProtocol
import Observation
import SwiftUI
/// One inspector per chat, owned outside recycled transcript cells. Its
/// identity stays fixed while its group and selected tool resolve from live data.
@MainActor @Observable
final class ToolInspectionState {
struct Selection {
let owner: String
var block: ToolCallBlock
}
struct GroupSelection {
let owner: String
var block: ToolGroupBlock
}
private(set) var tools: [String: ToolCallBlock] = [:]
private var groups: [String: ToolGroupBlock] = [:]
private var groupIDsByTool: [String: String] = [:]
private(set) var selection: Selection?
private(set) var groupSelection: GroupSelection?
private(set) var invalidation = 0
/// A group browser owns the presentation even when no detail is selected.
var owner: String? { groupSelection?.owner ?? selection?.owner }
var isGroupStale: Bool {
guard let groupSelection else { return false }
return groups[groupSelection.block.id] == nil
}
var isStale: Bool {
guard let selection else { return false }
return tools[selection.block.id] == nil
}
var siblingIDs: [String] {
guard let selection else { return [] }
if let groupSelection {
let ids = groupSelection.block.tools.map(\.id)
// Regrouping must not substitute a different tool at the same index.
return ids.contains(selection.block.id) ? ids : []
}
guard !isStale else { return [] }
return groupIDsByTool[selection.block.id].flatMap { groups[$0]?.tools.map(\.id) }
?? [selection.block.id]
}
var selectedIndex: Int? {
guard let selection else { return nil }
return siblingIDs.firstIndex(of: selection.block.id)
}
func update(_ blocks: [VisibleChatBlock]) {
var nextTools: [String: ToolCallBlock] = [:]
var nextGroups: [String: ToolGroupBlock] = [:]
var nextGroupIDsByTool: [String: String] = [:]
func collect(_ block: ChatBlock) {
guard case .toolCall(let tool) = block else { return }
nextTools[tool.id] = tool
tool.children.forEach(collect)
}
for block in blocks {
switch block {
case .block(let value): collect(value)
case .toolGroup(let group):
nextGroups[group.id] = group
for tool in group.tools {
nextGroupIDsByTool[tool.id] = group.id
collect(.toolCall(tool))
}
}
}
if tools != nextTools { tools = nextTools }
if groups != nextGroups { groups = nextGroups }
if groupIDsByTool != nextGroupIDsByTool { groupIDsByTool = nextGroupIDsByTool }
if let selected = groupSelection {
var latest = nextGroups[selected.block.id] ?? selected.block
if nextGroups[selected.block.id] == nil {
// Membership is a snapshot after regrouping, but remember each
// member's last available value when navigating back to it.
latest.tools = latest.tools.map { nextTools[$0.id] ?? $0 }
}
if latest != selected.block {
groupSelection = GroupSelection(owner: selected.owner, block: latest)
}
}
if let selected = selection, let latest = nextTools[selected.block.id], latest != selected.block {
selection = Selection(owner: selected.owner, block: latest)
}
}
func open(_ block: ToolCallBlock, owner: String) {
groupSelection = nil
selection = Selection(owner: owner, block: tools[block.id] ?? block)
}
@discardableResult
func openGroup(_ id: String, owner: String) -> Bool {
guard let group = groups[id] else { return false }
selection = nil
groupSelection = GroupSelection(owner: owner, block: group)
return true
}
@discardableResult
func selectGroupTool(_ id: String) -> Bool {
guard let groupSelection, let block = groupTool(id) else { return false }
selection = Selection(owner: groupSelection.owner, block: block)
return true
}
func groupTool(_ id: String) -> ToolCallBlock? {
guard let snapshot = groupSelection?.block.tools.first(where: { $0.id == id }) else { return nil }
return tools[id] ?? snapshot
}
func returnToGroup() {
if groupSelection != nil { selection = nil }
}
func dismiss(owner: String) {
guard self.owner == owner else { return }
selection = nil
groupSelection = nil
}
func move(by offset: Int) {
guard let selection, let index = selectedIndex else { return }
let ids = siblingIDs
let next = index + offset
guard ids.indices.contains(next) else { return }
if groupSelection != nil {
selectGroupTool(ids[next])
return
}
guard let block = tools[ids[next]] else { return }
self.selection = Selection(owner: selection.owner, block: block)
}
func invalidate() {
selection = nil
groupSelection = nil
tools = [:]
groups = [:]
groupIDsByTool = [:]
invalidation += 1
}
}
func opensToolProcess(_ block: ToolCallBlock) -> Bool {
!block.children.isEmpty || isSubagentToolName(block.tool.name) || block.tool.name == "CodexAgent"
}
private struct OpenChatToolKey: EnvironmentKey {
static let defaultValue: (@MainActor (ToolCallBlock) -> Void)? = nil
}
private struct OpenChatToolGroupKey: EnvironmentKey {
static let defaultValue: (@MainActor (String) -> Void)? = nil
}
extension EnvironmentValues {
var openChatToolGroup: (@MainActor (String) -> Void)? {
get { self[OpenChatToolGroupKey.self] }
set { self[OpenChatToolGroupKey.self] = newValue }
}
var openChatTool: (@MainActor (ToolCallBlock) -> Void)? {
get { self[OpenChatToolKey.self] }
set { self[OpenChatToolKey.self] = newValue }
}
}