feat: better UI

This commit is contained in:
Thomas Marchand
2025-12-17 09:49:04 +00:00
parent c11263db5b
commit e8c1927ed8
7 changed files with 621 additions and 1 deletions

View File

@@ -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;
};

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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