feat: better UI
This commit is contained in:
@@ -8,8 +8,12 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; };
|
||||
03176DF3878C25A0B557462C /* ToolUIOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */; };
|
||||
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; };
|
||||
1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */; };
|
||||
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */; };
|
||||
3361B14E949CB2A6E75B6962 /* ToolUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CBD2029F8CF6751AD7C4E2 /* ToolUIView.swift */; };
|
||||
3DD4D1D2E080C2F89C4881B7 /* ToolUIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6128ECBCA632D9E2D415F2 /* ToolUIModels.swift */; };
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504A1222CE8971417834D229 /* Theme.swift */; };
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */; };
|
||||
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */; };
|
||||
@@ -27,19 +31,23 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
02CBD2029F8CF6751AD7C4E2 /* ToolUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolUIView.swift; sourceTree = "<group>"; };
|
||||
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAgentDashboardApp.swift; sourceTree = "<group>"; };
|
||||
2B9834D4EE32058824F9DF00 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||
3729F39FBF53046124D05BC1 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = "<group>"; };
|
||||
3CB591B632D3EF26AB217976 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
|
||||
43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenAgentDashboard.entitlements; sourceTree = "<group>"; };
|
||||
45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolUIDataTableView.swift; sourceTree = "<group>"; };
|
||||
4D3D6B3EA3B04DE534F9709A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = "<group>"; };
|
||||
5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
|
||||
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
8A6128ECBCA632D9E2D415F2 /* ToolUIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolUIModels.swift; sourceTree = "<group>"; };
|
||||
99B57FC3136B64DC87413CA6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolUIOptionListView.swift; sourceTree = "<group>"; };
|
||||
A84519FDE8FC75084938B292 /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = "<group>"; };
|
||||
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
@@ -89,6 +97,7 @@
|
||||
EB5A4720378F06807FDE73E1 /* GlassCard.swift */,
|
||||
2B9834D4EE32058824F9DF00 /* LoadingView.swift */,
|
||||
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */,
|
||||
D09E84E812213CF7E52E4FEF /* ToolUI */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -147,6 +156,17 @@
|
||||
path = OpenAgentDashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D09E84E812213CF7E52E4FEF /* ToolUI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */,
|
||||
8A6128ECBCA632D9E2D415F2 /* ToolUIModels.swift */,
|
||||
A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */,
|
||||
02CBD2029F8CF6751AD7C4E2 /* ToolUIView.swift */,
|
||||
);
|
||||
path = ToolUI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DABAA3652C0B0A54CFC3221B /* Control */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -250,6 +270,10 @@
|
||||
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */,
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */,
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */,
|
||||
1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */,
|
||||
3DD4D1D2E080C2F89C4881B7 /* ToolUIModels.swift in Sources */,
|
||||
03176DF3878C25A0B557462C /* ToolUIOptionListView.swift in Sources */,
|
||||
3361B14E949CB2A6E75B6962 /* ToolUIView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ enum ChatMessageType {
|
||||
case user
|
||||
case assistant(success: Bool, costCents: Int, model: String?)
|
||||
case thinking(done: Bool, startTime: Date)
|
||||
case toolUI(name: String)
|
||||
case system
|
||||
case error
|
||||
}
|
||||
@@ -19,12 +20,14 @@ struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let type: ChatMessageType
|
||||
var content: String
|
||||
var toolUI: ToolUIContent?
|
||||
let timestamp: Date
|
||||
|
||||
init(id: String = UUID().uuidString, type: ChatMessageType, content: String, timestamp: Date = Date()) {
|
||||
init(id: String = UUID().uuidString, type: ChatMessageType, content: String, toolUI: ToolUIContent? = nil, timestamp: Date = Date()) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.toolUI = toolUI
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
@@ -43,6 +46,11 @@ struct ChatMessage: Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
var isToolUI: Bool {
|
||||
if case .toolUI = type { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var thinkingDone: Bool {
|
||||
if case .thinking(let done, _) = type { return done }
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ToolUIDataTableView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// SwiftUI renderer for ui_dataTable tool
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ToolUIDataTableView: View {
|
||||
let table: ToolUIDataTable
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Title
|
||||
if let title = table.title {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.backgroundSecondary.opacity(0.5))
|
||||
}
|
||||
|
||||
// Table content
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header row
|
||||
HStack(spacing: 0) {
|
||||
ForEach(table.columns, id: \.id) { column in
|
||||
Text(column.displayLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
.frame(minWidth: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.background(Theme.backgroundSecondary.opacity(0.3))
|
||||
|
||||
Divider()
|
||||
.background(Theme.border)
|
||||
|
||||
// Data rows
|
||||
if table.rows.isEmpty {
|
||||
Text("No data")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
ForEach(Array(table.rows.enumerated()), id: \.offset) { index, row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(table.columns, id: \.id) { column in
|
||||
let cellValue = row[column.id]?.stringValue ?? "-"
|
||||
Text(cellValue)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(minWidth: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
if index < table.rows.count - 1 {
|
||||
Divider()
|
||||
.background(Theme.border.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func columnWidth(for column: ToolUIDataTable.Column) -> CGFloat {
|
||||
// Parse width if provided, otherwise use default
|
||||
if let width = column.width {
|
||||
if width.hasSuffix("px") {
|
||||
let numStr = width.dropLast(2)
|
||||
if let num = Double(numStr) {
|
||||
return CGFloat(num)
|
||||
}
|
||||
}
|
||||
if let num = Double(width) {
|
||||
return CGFloat(num)
|
||||
}
|
||||
}
|
||||
return 120 // Default column width
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleTable = ToolUIDataTable.Column(id: "model", label: "Model", width: nil)
|
||||
|
||||
VStack {
|
||||
// Preview would go here
|
||||
Text("Data Table Preview")
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.backgroundPrimary)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//
|
||||
// ToolUIModels.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Data models for tool UI components
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Data Table
|
||||
|
||||
struct ToolUIDataTable: Codable {
|
||||
let id: String?
|
||||
let title: String?
|
||||
let columns: [Column]
|
||||
let rows: [[String: AnyCodable]]
|
||||
|
||||
struct Column: Codable {
|
||||
let id: String
|
||||
let label: String?
|
||||
let width: String?
|
||||
|
||||
var displayLabel: String {
|
||||
label ?? id.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
rows = try container.decodeIfPresent([[String: AnyCodable]].self, forKey: .rows) ?? []
|
||||
|
||||
// Columns can be strings or objects
|
||||
if let columnObjects = try? container.decode([Column].self, forKey: .columns) {
|
||||
columns = columnObjects
|
||||
} else if let columnStrings = try? container.decode([String].self, forKey: .columns) {
|
||||
columns = columnStrings.map { Column(id: $0, label: $0, width: nil) }
|
||||
} else {
|
||||
columns = []
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, columns, rows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Option List
|
||||
|
||||
struct ToolUIOptionList: Codable {
|
||||
let id: String?
|
||||
let options: [Option]
|
||||
let selectionMode: String?
|
||||
let defaultValue: AnyCodable?
|
||||
let confirmed: AnyCodable?
|
||||
|
||||
struct Option: Codable, Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let description: String?
|
||||
let disabled: Bool?
|
||||
}
|
||||
|
||||
var isSingleSelect: Bool {
|
||||
selectionMode != "multi"
|
||||
}
|
||||
|
||||
var confirmedIds: [String] {
|
||||
guard let confirmed = confirmed else { return [] }
|
||||
if let str = confirmed.value as? String {
|
||||
return [str]
|
||||
}
|
||||
if let arr = confirmed.value as? [String] {
|
||||
return arr
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Any Codable Helper
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map { $0.value }
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues { $0.value }
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unable to decode value")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
switch value {
|
||||
case is NSNull:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
try container.encode(String(describing: value))
|
||||
}
|
||||
}
|
||||
|
||||
var stringValue: String {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
return "-"
|
||||
case let bool as Bool:
|
||||
return bool ? "Yes" : "No"
|
||||
case let int as Int:
|
||||
return int.formatted()
|
||||
case let double as Double:
|
||||
return double.formatted(.number.precision(.fractionLength(0...2)))
|
||||
case let string as String:
|
||||
return string
|
||||
default:
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Call Wrapper
|
||||
|
||||
enum ToolUIContent: Identifiable {
|
||||
case dataTable(ToolUIDataTable)
|
||||
case optionList(ToolUIOptionList)
|
||||
case unknown(name: String, args: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .dataTable(let table):
|
||||
return table.id ?? UUID().uuidString
|
||||
case .optionList(let list):
|
||||
return list.id ?? UUID().uuidString
|
||||
case .unknown(let name, _):
|
||||
return "unknown-\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
static func parse(name: String, args: [String: Any]) -> ToolUIContent? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: args) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
switch name {
|
||||
case "ui_dataTable":
|
||||
if let table = try? decoder.decode(ToolUIDataTable.self, from: data) {
|
||||
return .dataTable(table)
|
||||
}
|
||||
case "ui_optionList":
|
||||
if let list = try? decoder.decode(ToolUIOptionList.self, from: data) {
|
||||
return .optionList(list)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Return unknown for any unrecognized UI tool
|
||||
if name.hasPrefix("ui_") {
|
||||
let argsString = String(data: data, encoding: .utf8) ?? "{}"
|
||||
return .unknown(name: name, args: argsString)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// ToolUIOptionListView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// SwiftUI renderer for ui_optionList tool
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ToolUIOptionListView: View {
|
||||
let optionList: ToolUIOptionList
|
||||
let onSelect: ((String) -> Void)?
|
||||
|
||||
@State private var selectedIds: Set<String> = []
|
||||
|
||||
init(optionList: ToolUIOptionList, onSelect: ((String) -> Void)? = nil) {
|
||||
self.optionList = optionList
|
||||
self.onSelect = onSelect
|
||||
|
||||
// Initialize with confirmed values if present
|
||||
let confirmed = optionList.confirmedIds
|
||||
_selectedIds = State(initialValue: Set(confirmed))
|
||||
}
|
||||
|
||||
var isConfirmed: Bool {
|
||||
!optionList.confirmedIds.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(optionList.options) { option in
|
||||
if isConfirmed {
|
||||
// Only show confirmed options
|
||||
if optionList.confirmedIds.contains(option.id) {
|
||||
confirmedOptionRow(option)
|
||||
}
|
||||
} else {
|
||||
// Show all options as selectable
|
||||
selectableOptionRow(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func confirmedOptionRow(_ option: ToolUIOptionList.Option) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.success)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(option.label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
if let description = option.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Theme.success.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func selectableOptionRow(_ option: ToolUIOptionList.Option) -> some View {
|
||||
let isSelected = selectedIds.contains(option.id)
|
||||
let isDisabled = option.disabled ?? false
|
||||
|
||||
return Button {
|
||||
guard !isDisabled else { return }
|
||||
|
||||
if optionList.isSingleSelect {
|
||||
selectedIds = [option.id]
|
||||
} else {
|
||||
if selectedIds.contains(option.id) {
|
||||
selectedIds.remove(option.id)
|
||||
} else {
|
||||
selectedIds.insert(option.id)
|
||||
}
|
||||
}
|
||||
|
||||
HapticService.selectionChanged()
|
||||
onSelect?(option.id)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
// Selection indicator
|
||||
Image(systemName: isSelected ?
|
||||
(optionList.isSingleSelect ? "circle.inset.filled" : "checkmark.square.fill") :
|
||||
(optionList.isSingleSelect ? "circle" : "square"))
|
||||
.foregroundStyle(isSelected ? Theme.accent : Theme.textMuted)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(option.label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(isDisabled ? Theme.textMuted : Theme.textPrimary)
|
||||
|
||||
if let description = option.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(isSelected ? Theme.accent.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(isSelected ? Theme.accent.opacity(0.3) : Theme.border.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
Text("Option List Preview")
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.backgroundPrimary)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// ToolUIView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Main renderer for tool UI components
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ToolUIView: View {
|
||||
let content: ToolUIContent
|
||||
let onOptionSelect: ((String, String) -> Void)?
|
||||
|
||||
init(content: ToolUIContent, onOptionSelect: ((String, String) -> Void)? = nil) {
|
||||
self.content = content
|
||||
self.onOptionSelect = onOptionSelect
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Tool label
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: toolIcon)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.accent)
|
||||
|
||||
Text("Tool: ")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
+
|
||||
Text(toolName)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.accent.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
|
||||
// Tool content
|
||||
toolContent
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolContent: some View {
|
||||
switch content {
|
||||
case .dataTable(let table):
|
||||
ToolUIDataTableView(table: table)
|
||||
|
||||
case .optionList(let list):
|
||||
ToolUIOptionListView(optionList: list) { optionId in
|
||||
if let listId = list.id {
|
||||
onOptionSelect?(listId, optionId)
|
||||
}
|
||||
}
|
||||
|
||||
case .unknown(let name, let args):
|
||||
unknownToolView(name: name, args: args)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolIcon: String {
|
||||
switch content {
|
||||
case .dataTable:
|
||||
return "tablecells"
|
||||
case .optionList:
|
||||
return "list.bullet"
|
||||
case .unknown:
|
||||
return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var toolName: String {
|
||||
switch content {
|
||||
case .dataTable:
|
||||
return "ui_dataTable"
|
||||
case .optionList:
|
||||
return "ui_optionList"
|
||||
case .unknown(let name, _):
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private func unknownToolView(name: String, args: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Unknown Tool UI")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
Text(args)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
Text("Tool UI Preview")
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.backgroundPrimary)
|
||||
}
|
||||
@@ -477,6 +477,22 @@ struct ControlView: View {
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
if let toolCallId = data["tool_call_id"] as? String,
|
||||
let name = data["name"] as? String,
|
||||
let args = data["args"] as? [String: Any] {
|
||||
// Parse UI tool calls
|
||||
if let toolUI = ToolUIContent.parse(name: name, args: args) {
|
||||
let message = ChatMessage(
|
||||
id: toolCallId,
|
||||
type: .toolUI(name: name),
|
||||
content: "",
|
||||
toolUI: toolUI
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -496,6 +512,9 @@ private struct MessageBubble: View {
|
||||
} else if message.isThinking {
|
||||
thinkingBubble
|
||||
Spacer(minLength: 60)
|
||||
} else if message.isToolUI {
|
||||
toolUIBubble
|
||||
Spacer(minLength: 40)
|
||||
} else {
|
||||
assistantBubble
|
||||
Spacer(minLength: 60)
|
||||
@@ -503,6 +522,13 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolUIBubble: some View {
|
||||
if let toolUI = message.toolUI {
|
||||
ToolUIView(content: toolUI)
|
||||
}
|
||||
}
|
||||
|
||||
private var userBubble: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(message.content)
|
||||
|
||||
Reference in New Issue
Block a user