From d7a6b846c3d9b382b27770274337466c12994d65 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Wed, 17 Dec 2025 08:36:06 +0000 Subject: [PATCH] feat: add stdio MCP transport support - Add McpTransport enum with Http and Stdio variants - Update McpServerConfig to support both transport types - Implement stdio process spawning and JSON-RPC communication - Store tool descriptors with full metadata (name, description, schema) - Maintain backwards compatibility with existing HTTP MCPs This enables using community MCP servers that use stdio transport (which is the standard for most MCP servers). --- .../project.pbxproj | 452 +++++++++++++++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 38 ++ .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 20 + .../OpenAgentDashboard/ContentView.swift | 274 +++++++++ .../DesignSystem/Theme.swift | 212 +++++++ ios_dashboard/OpenAgentDashboard/Info.plist | 58 ++ .../Models/ChatMessage.swift | 90 +++ .../OpenAgentDashboard/Models/FileEntry.swift | 71 +++ .../OpenAgentDashboard/Models/Mission.swift | 131 +++++ .../OpenAgentDashboard.entitlements | 6 + .../OpenAgentDashboardApp.swift | 18 + .../Services/APIService.swift | 364 ++++++++++++ .../Views/Components/GlassButton.swift | 248 ++++++++ .../Views/Components/GlassCard.swift | 174 ++++++ .../Views/Components/LoadingView.swift | 144 +++++ .../Views/Components/StatusBadge.swift | 155 +++++ .../Views/Control/ControlView.swift | 538 ++++++++++++++++++ .../Views/Files/FilesView.swift | 407 +++++++++++++ .../Views/History/HistoryView.swift | 454 +++++++++++++++ .../Views/Terminal/TerminalView.swift | 330 +++++++++++ ios_dashboard/Package.swift | 18 + ios_dashboard/project.yml | 38 ++ src/mcp/registry.rs | 433 +++++++++++--- src/mcp/types.rs | 91 ++- 27 files changed, 4711 insertions(+), 79 deletions(-) create mode 100644 ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj create mode 100644 ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios_dashboard/OpenAgentDashboard/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios_dashboard/OpenAgentDashboard/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios_dashboard/OpenAgentDashboard/Assets.xcassets/Contents.json create mode 100644 ios_dashboard/OpenAgentDashboard/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 ios_dashboard/OpenAgentDashboard/ContentView.swift create mode 100644 ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Info.plist create mode 100644 ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Models/Mission.swift create mode 100644 ios_dashboard/OpenAgentDashboard/OpenAgentDashboard.entitlements create mode 100644 ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Services/APIService.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Components/GlassButton.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Components/GlassCard.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Components/LoadingView.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift create mode 100644 ios_dashboard/Package.swift create mode 100644 ios_dashboard/project.yml diff --git a/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8010b41 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj @@ -0,0 +1,452 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; }; + 0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; }; + 29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.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 */; }; + 6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9834D4EE32058824F9DF00 /* LoadingView.swift */; }; + 6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5908645A518F48B501390AB8 /* FilesView.swift */; }; + 999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5A4720378F06807FDE73E1 /* GlassCard.swift */; }; + 9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AB47CF121ABA1946A4D879 /* Mission.swift */; }; + AA02567226057045DDD61CB1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B57FC3136B64DC87413CA6 /* ContentView.swift */; }; + CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB591B632D3EF26AB217976 /* ChatMessage.swift */; }; + D64972881E36894950658708 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC90C32FEF604E025FFBF78 /* APIService.swift */; }; + DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DE67017A858357F68424 /* GlassButton.swift */; }; + FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */; }; + FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAgentDashboardApp.swift; sourceTree = ""; }; + 2B9834D4EE32058824F9DF00 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + 3CB591B632D3EF26AB217976 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + 43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenAgentDashboard.entitlements; sourceTree = ""; }; + 4D3D6B3EA3B04DE534F9709A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = ""; }; + 5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; + 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 99B57FC3136B64DC87413CA6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A84519FDE8FC75084938B292 /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = ""; }; + BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; + CBC90C32FEF604E025FFBF78 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = ""; }; + D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = ""; }; + EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; + F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OpenAgentDashboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0C1185300420EEF31B892A3A /* Files */ = { + isa = PBXGroup; + children = ( + 5908645A518F48B501390AB8 /* FilesView.swift */, + ); + path = Files; + sourceTree = ""; + }; + 0D9369EE2F3374EAA1EF332E /* Terminal */ = { + isa = PBXGroup; + children = ( + 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */, + ); + path = Terminal; + sourceTree = ""; + }; + 1B2400F48D7D400DF42A11F0 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 504A1222CE8971417834D229 /* Theme.swift */, + ); + path = DesignSystem; + sourceTree = ""; + }; + 279F9B8FE97DDCBF76C2E85E /* Products */ = { + isa = PBXGroup; + children = ( + F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */, + ); + name = Products; + sourceTree = ""; + }; + 2EF415E84544334B25BD8E26 /* Components */ = { + isa = PBXGroup; + children = ( + 5267DE67017A858357F68424 /* GlassButton.swift */, + EB5A4720378F06807FDE73E1 /* GlassCard.swift */, + 2B9834D4EE32058824F9DF00 /* LoadingView.swift */, + CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5A40B212F0D2055C1C499FCC /* History */ = { + isa = PBXGroup; + children = ( + 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */, + ); + path = History; + sourceTree = ""; + }; + 73D80C56FA670F92E007E712 /* Views */ = { + isa = PBXGroup; + children = ( + 2EF415E84544334B25BD8E26 /* Components */, + DABAA3652C0B0A54CFC3221B /* Control */, + 0C1185300420EEF31B892A3A /* Files */, + 5A40B212F0D2055C1C499FCC /* History */, + 0D9369EE2F3374EAA1EF332E /* Terminal */, + ); + path = Views; + sourceTree = ""; + }; + AB86DCEEB152D8EA7E8CBD86 = { + isa = PBXGroup; + children = ( + C86E333A0549E3B163391090 /* OpenAgentDashboard */, + 279F9B8FE97DDCBF76C2E85E /* Products */, + ); + sourceTree = ""; + }; + C786EDDB39D9D19A1A112CE9 /* Models */ = { + isa = PBXGroup; + children = ( + 3CB591B632D3EF26AB217976 /* ChatMessage.swift */, + BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */, + D4AB47CF121ABA1946A4D879 /* Mission.swift */, + ); + path = Models; + sourceTree = ""; + }; + C86E333A0549E3B163391090 /* OpenAgentDashboard */ = { + isa = PBXGroup; + children = ( + 66A48A20D2178760301256C9 /* Assets.xcassets */, + 99B57FC3136B64DC87413CA6 /* ContentView.swift */, + 4D3D6B3EA3B04DE534F9709A /* Info.plist */, + 43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */, + 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */, + 1B2400F48D7D400DF42A11F0 /* DesignSystem */, + C786EDDB39D9D19A1A112CE9 /* Models */, + E9CA77690CC753DF6D133ACC /* Services */, + 73D80C56FA670F92E007E712 /* Views */, + ); + path = OpenAgentDashboard; + sourceTree = ""; + }; + DABAA3652C0B0A54CFC3221B /* Control */ = { + isa = PBXGroup; + children = ( + A84519FDE8FC75084938B292 /* ControlView.swift */, + ); + path = Control; + sourceTree = ""; + }; + E9CA77690CC753DF6D133ACC /* Services */ = { + isa = PBXGroup; + children = ( + CBC90C32FEF604E025FFBF78 /* APIService.swift */, + ); + path = Services; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DD68473111E6CED00E695F44 /* OpenAgentDashboard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */; + buildPhases = ( + BE523DA1714AE19926D7309A /* Sources */, + F834FCE2F6EA811F16BF98AE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenAgentDashboard; + packageProductDependencies = ( + ); + productName = OpenAgentDashboard; + productReference = F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F2797B25B56CE919907DC4F7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + TargetAttributes = { + DD68473111E6CED00E695F44 = { + DevelopmentTeam = ""; + }; + }; + }; + buildConfigurationList = DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = AB86DCEEB152D8EA7E8CBD86; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DD68473111E6CED00E695F44 /* OpenAgentDashboard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F834FCE2F6EA811F16BF98AE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + BE523DA1714AE19926D7309A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D64972881E36894950658708 /* APIService.swift in Sources */, + CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */, + AA02567226057045DDD61CB1 /* ContentView.swift in Sources */, + 02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */, + 5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */, + 6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */, + DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */, + 999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */, + 29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */, + 6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */, + 9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */, + FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */, + FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */, + 4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */, + 4B50B97618C0CC469FF64592 /* Theme.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 387AE8B7392A5AF971AD749A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenAgentDashboard/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard; + PRODUCT_NAME = "Open Agent"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9A248EB7DD7B3E88A6324395 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + ADC68A4DC006EED0F9D123CC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenAgentDashboard/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard; + PRODUCT_NAME = "Open Agent"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B45295B3864E2C7973AE65C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 387AE8B7392A5AF971AD749A /* Debug */, + ADC68A4DC006EED0F9D123CC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A248EB7DD7B3E88A6324395 /* Debug */, + B45295B3864E2C7973AE65C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = F2797B25B56CE919907DC4F7 /* Project object */; +} diff --git a/ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AccentColor.colorset/Contents.json b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..49eb9fd --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.400", + "red" : "0.388" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.400", + "red" : "0.388" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Assets.xcassets/Contents.json b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..8f65f87 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.071", + "red" : "0.071" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_dashboard/OpenAgentDashboard/ContentView.swift b/ios_dashboard/OpenAgentDashboard/ContentView.swift new file mode 100644 index 0000000..211cd5a --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/ContentView.swift @@ -0,0 +1,274 @@ +// +// ContentView.swift +// OpenAgentDashboard +// +// Main content view with authentication gate and tab navigation +// + +import SwiftUI + +struct ContentView: View { + @State private var isAuthenticated = false + @State private var isCheckingAuth = true + @State private var authRequired = false + + private let api = APIService.shared + + var body: some View { + Group { + if isCheckingAuth { + LoadingView(message: "Connecting...") + .background(Theme.backgroundPrimary.ignoresSafeArea()) + } else if authRequired && !isAuthenticated { + LoginView(onLogin: { isAuthenticated = true }) + } else { + MainTabView() + } + } + .task { + await checkAuth() + } + } + + private func checkAuth() async { + isCheckingAuth = true + + do { + let _ = try await api.checkHealth() + authRequired = api.authRequired + isAuthenticated = api.isAuthenticated || !authRequired + } catch { + // If health check fails, assume we need auth + authRequired = true + isAuthenticated = api.isAuthenticated + } + + isCheckingAuth = false + } +} + +// MARK: - Login View + +struct LoginView: View { + let onLogin: () -> Void + + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var serverURL: String + + @FocusState private var isPasswordFocused: Bool + + private let api = APIService.shared + + init(onLogin: @escaping () -> Void) { + self.onLogin = onLogin + _serverURL = State(initialValue: APIService.shared.baseURL) + } + + var body: some View { + ZStack { + // Background + Theme.backgroundPrimary.ignoresSafeArea() + + // Gradient accents + RadialGradient( + colors: [Theme.accent.opacity(0.15), .clear], + center: .topTrailing, + startRadius: 50, + endRadius: 400 + ) + .ignoresSafeArea() + + RadialGradient( + colors: [Color.purple.opacity(0.1), .clear], + center: .bottomLeading, + startRadius: 50, + endRadius: 400 + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 32) { + Spacer() + .frame(height: 60) + + // Logo + VStack(spacing: 16) { + Image(systemName: "brain.head.profile") + .font(.system(size: 64)) + .foregroundStyle(Theme.accent) + .symbolEffect(.pulse, options: .repeating) + + VStack(spacing: 4) { + Text("Open Agent") + .font(.largeTitle.bold()) + .foregroundStyle(Theme.textPrimary) + + Text("Dashboard") + .font(.title3) + .foregroundStyle(Theme.textSecondary) + } + } + + // Login form + GlassCard(padding: 24, cornerRadius: 28) { + VStack(spacing: 20) { + // Server URL field + VStack(alignment: .leading, spacing: 8) { + Text("Server URL") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textSecondary) + + TextField("https://agent-backend.example.com", text: $serverURL) + .textFieldStyle(.plain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + ) + } + + // Password field + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textSecondary) + + SecureField("Enter password", text: $password) + .textFieldStyle(.plain) + .focused($isPasswordFocused) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isPasswordFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1) + ) + .onSubmit { + login() + } + } + + // Error message + if let error = errorMessage { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(Theme.error) + Text(error) + .font(.caption) + .foregroundStyle(Theme.error) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Login button + GlassPrimaryButton( + "Sign In", + icon: "arrow.right", + isLoading: isLoading, + isDisabled: password.isEmpty + ) { + login() + } + } + } + .padding(.horizontal, 24) + + Spacer() + } + } + } + } + + private func login() { + guard !password.isEmpty else { return } + + // Update server URL + api.baseURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + + isLoading = true + errorMessage = nil + + Task { + do { + let _ = try await api.login(password: password) + HapticService.success() + onLogin() + } catch { + errorMessage = error.localizedDescription + HapticService.error() + } + isLoading = false + } + } +} + +// MARK: - Main Tab View + +struct MainTabView: View { + @State private var selectedTab: TabItem = .control + + enum TabItem: String, CaseIterable { + case control = "Control" + case history = "History" + case terminal = "Terminal" + case files = "Files" + + var icon: String { + switch self { + case .control: return "message.fill" + case .history: return "clock.fill" + case .terminal: return "terminal.fill" + case .files: return "folder.fill" + } + } + } + + var body: some View { + TabView(selection: $selectedTab) { + ForEach(TabItem.allCases, id: \.rawValue) { tab in + NavigationStack { + tabContent(for: tab) + } + .tabItem { + Label(tab.rawValue, systemImage: tab.icon) + } + .tag(tab) + } + } + .tint(Theme.accent) + .onChange(of: selectedTab) { _, _ in + HapticService.selectionChanged() + } + } + + @ViewBuilder + private func tabContent(for tab: TabItem) -> some View { + switch tab { + case .control: + ControlView() + case .history: + HistoryView() + case .terminal: + TerminalView() + case .files: + FilesView() + } + } +} + +#Preview("Login") { + LoginView(onLogin: {}) +} + +#Preview("Main") { + MainTabView() +} diff --git a/ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift b/ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift new file mode 100644 index 0000000..6808292 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift @@ -0,0 +1,212 @@ +// +// Theme.swift +// OpenAgentDashboard +// +// Native-first, quiet confidence theme tokens +// "Quiet Luxury + Liquid Glass" - Dark-first, Vercel/shadcn inspired +// + +import SwiftUI + +enum Theme { + + // MARK: - Surfaces + // Deep charcoal backgrounds - avoid pure black for quiet luxury feel + + /// Primary background: #121214 - deep charcoal, not pure black + static let backgroundPrimary = Color( + uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.071, green: 0.071, blue: 0.078, alpha: 1.0) + : UIColor.systemBackground + } + ) + + /// Secondary/elevated background: #1C1C1E - iOS system secondary background + static let backgroundSecondary = Color(uiColor: .secondarySystemBackground) + + /// Tertiary background: #2C2C2E - for nested elements + static let backgroundTertiary = Color( + uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0) + : UIColor.tertiarySystemBackground + } + ) + + /// Card surface: subtle elevation from background + static let card = Color( + uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0) + : UIColor.secondarySystemBackground + } + ) + + /// Elevated card: for nested or interactive elements + static let cardElevated = Color( + uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0) + : UIColor.tertiarySystemBackground + } + ) + + /// Subtle divider/hairline + static let hairline = Color(uiColor: .separator) + + /// Border color with low opacity + static let border = Color.white.opacity(0.06) + static let borderElevated = Color.white.opacity(0.08) + + // MARK: - Accent + // Single accent color for primary actions - indigo per style guide + static let accent = Color.indigo + static let accentLight = Color(red: 0.388, green: 0.4, blue: 0.945) + + // MARK: - Semantic Colors + static let success = Color(red: 0.133, green: 0.773, blue: 0.369) // #22C55E + static let warning = Color(red: 0.918, green: 0.702, blue: 0.031) // #EAB308 + static let error = Color(red: 0.937, green: 0.267, blue: 0.267) // #EF4444 + static let info = Color(red: 0.231, green: 0.510, blue: 0.965) // #3B82F6 + + // MARK: - Text + // Use semantic colors for proper dark/light mode support + static let textPrimary = Color(uiColor: .label) + static let textSecondary = Color(uiColor: .secondaryLabel) + static let textTertiary = Color(uiColor: .tertiaryLabel) + static let textMuted = Color.white.opacity(0.4) + + // MARK: - Typography Helpers + + static func metric(_ value: Double) -> Text { + Text(value, format: .number.precision(.fractionLength(0))) + .monospacedDigit() + } + + static func metric(_ value: Int) -> Text { + Text("\(value)") + .monospacedDigit() + } +} + +// MARK: - View Extensions + +extension View { + /// Apply the primary background + func themeBackground() -> some View { + background(Theme.backgroundPrimary.ignoresSafeArea()) + } + + /// Card style with subtle elevation + func themeCard() -> some View { + self + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + /// Elevated card style + func themeCardElevated() -> some View { + self + .background(Theme.cardElevated) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + /// Apply subtle border + func themeBorder() -> some View { + self.overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + ) + } +} + +// MARK: - Button Styles + +struct GlassButtonStyle: ButtonStyle { + var isProminent: Bool = false + + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + if isProminent { + configuration.label + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.accent) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.1), lineWidth: 0.5) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } else { + configuration.label + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.15), lineWidth: 0.5) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } + } +} + +struct GlassProminentButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 20) + .padding(.vertical, 14) + .foregroundStyle(.white) + .background( + LinearGradient( + colors: [Theme.accent, Theme.accent.opacity(0.85)], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +extension ButtonStyle where Self == GlassButtonStyle { + static var glass: GlassButtonStyle { GlassButtonStyle() } + static var glassProminent: GlassButtonStyle { GlassButtonStyle(isProminent: true) } +} + +// MARK: - Haptics + +@MainActor +enum HapticService { + static func lightTap() { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } + + static func mediumTap() { + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + } + + static func selectionChanged() { + let generator = UISelectionFeedbackGenerator() + generator.selectionChanged() + } + + static func success() { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } + + static func error() { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Info.plist b/ios_dashboard/OpenAgentDashboard/Info.plist new file mode 100644 index 0000000..9c11e74 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Open Agent + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift b/ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift new file mode 100644 index 0000000..0a1bc3d --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift @@ -0,0 +1,90 @@ +// +// ChatMessage.swift +// OpenAgentDashboard +// +// Chat message models for the control view +// + +import Foundation + +enum ChatMessageType { + case user + case assistant(success: Bool, costCents: Int, model: String?) + case thinking(done: Bool, startTime: Date) + case system + case error +} + +struct ChatMessage: Identifiable { + let id: String + let type: ChatMessageType + var content: String + let timestamp: Date + + init(id: String = UUID().uuidString, type: ChatMessageType, content: String, timestamp: Date = Date()) { + self.id = id + self.type = type + self.content = content + self.timestamp = timestamp + } + + var isUser: Bool { + if case .user = type { return true } + return false + } + + var isAssistant: Bool { + if case .assistant = type { return true } + return false + } + + var isThinking: Bool { + if case .thinking = type { return true } + return false + } + + var thinkingDone: Bool { + if case .thinking(let done, _) = type { return done } + return false + } + + var displayModel: String? { + if case .assistant(_, _, let model) = type { + if let model = model { + return model.split(separator: "/").last.map(String.init) + } + } + return nil + } + + var costFormatted: String? { + if case .assistant(_, let costCents, _) = type, costCents > 0 { + return String(format: "$%.4f", Double(costCents) / 100.0) + } + return nil + } +} + +// MARK: - Control Session State + +enum ControlRunState: String, Codable { + case idle + case running + case waitingForTool = "waiting_for_tool" + + var statusType: StatusType { + switch self { + case .idle: return .idle + case .running: return .running + case .waitingForTool: return .pending + } + } + + var label: String { + switch self { + case .idle: return "Idle" + case .running: return "Running" + case .waitingForTool: return "Waiting" + } + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift b/ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift new file mode 100644 index 0000000..80fa344 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift @@ -0,0 +1,71 @@ +// +// FileEntry.swift +// OpenAgentDashboard +// +// File system entry models +// + +import Foundation + +struct FileEntry: Codable, Identifiable { + let name: String + let path: String + let kind: String + let size: Int + let mtime: Int + + var id: String { path } + + var isDirectory: Bool { + kind == "dir" + } + + var isFile: Bool { + kind == "file" + } + + var icon: String { + if isDirectory { + return "folder.fill" + } + + let ext = (name as NSString).pathExtension.lowercased() + switch ext { + case "swift", "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "c", "cpp", "h": + return "doc.text.fill" + case "json", "yaml", "yml", "toml", "xml": + return "doc.badge.gearshape.fill" + case "md", "txt", "log": + return "doc.plaintext.fill" + case "png", "jpg", "jpeg", "gif", "svg", "webp": + return "photo.fill" + case "pdf": + return "doc.richtext.fill" + case "zip", "tar", "gz", "rar": + return "doc.zipper" + default: + return "doc.fill" + } + } + + var formattedSize: String { + guard isFile else { return "—" } + if size < 1024 { return "\(size) B" } + + let units = ["KB", "MB", "GB", "TB"] + var value = Double(size) / 1024.0 + var unitIndex = 0 + + while value >= 1024 && unitIndex < units.count - 1 { + value /= 1024.0 + unitIndex += 1 + } + + return value >= 10 ? String(format: "%.0f %@", value, units[unitIndex]) + : String(format: "%.1f %@", value, units[unitIndex]) + } + + var modifiedDate: Date { + Date(timeIntervalSince1970: TimeInterval(mtime)) + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Models/Mission.swift b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift new file mode 100644 index 0000000..07afdf8 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift @@ -0,0 +1,131 @@ +// +// Mission.swift +// OpenAgentDashboard +// +// Mission and task data models +// + +import Foundation + +enum MissionStatus: String, Codable, CaseIterable { + case active + case completed + case failed + + var statusType: StatusType { + switch self { + case .active: return .active + case .completed: return .completed + case .failed: return .failed + } + } +} + +struct MissionHistoryEntry: Codable, Identifiable { + var id: String { "\(role)-\(content.prefix(20))" } + let role: String + let content: String + + var isUser: Bool { + role == "user" + } +} + +struct Mission: Codable, Identifiable, Hashable { + let id: String + var status: MissionStatus + let title: String? + let history: [MissionHistoryEntry] + let createdAt: String + let updatedAt: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Mission, rhs: Mission) -> Bool { + lhs.id == rhs.id + } + + enum CodingKeys: String, CodingKey { + case id, status, title, history + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + var displayTitle: String { + if let title = title, !title.isEmpty { + return title.count > 60 ? String(title.prefix(60)) + "..." : title + } + return "Untitled Mission" + } + + var updatedDate: Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) + } +} + +enum TaskStatus: String, Codable, CaseIterable { + case pending + case running + case completed + case failed + case cancelled + + var statusType: StatusType { + switch self { + case .pending: return .pending + case .running: return .running + case .completed: return .completed + case .failed: return .failed + case .cancelled: return .cancelled + } + } +} + +struct TaskState: Codable, Identifiable { + let id: String + let status: TaskStatus + let task: String + let model: String + let iterations: Int + let result: String? + + var displayModel: String { + if let lastPart = model.split(separator: "/").last { + return String(lastPart) + } + return model + } +} + +struct Run: Codable, Identifiable { + let id: String + let createdAt: String + let status: String + let inputText: String + let finalOutput: String? + let totalCostCents: Int + let summaryText: String? + + enum CodingKeys: String, CodingKey { + case id, status + case createdAt = "created_at" + case inputText = "input_text" + case finalOutput = "final_output" + case totalCostCents = "total_cost_cents" + case summaryText = "summary_text" + } + + var costDollars: Double { + Double(totalCostCents) / 100.0 + } + + var createdDate: Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: createdAt) ?? ISO8601DateFormatter().date(from: createdAt) + } +} diff --git a/ios_dashboard/OpenAgentDashboard/OpenAgentDashboard.entitlements b/ios_dashboard/OpenAgentDashboard/OpenAgentDashboard.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/OpenAgentDashboard.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift b/ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift new file mode 100644 index 0000000..f028de9 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift @@ -0,0 +1,18 @@ +// +// OpenAgentDashboardApp.swift +// OpenAgentDashboard +// +// iOS Dashboard for Open Agent with liquid glass design +// + +import SwiftUI + +@main +struct OpenAgentDashboardApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .preferredColorScheme(.dark) + } + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Services/APIService.swift b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift new file mode 100644 index 0000000..c45d870 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift @@ -0,0 +1,364 @@ +// +// APIService.swift +// OpenAgentDashboard +// +// HTTP API client for the Open Agent backend +// + +import Foundation +import Observation + +@MainActor +@Observable +final class APIService { + static let shared = APIService() + nonisolated init() {} + + // Configuration + var baseURL: String { + get { UserDefaults.standard.string(forKey: "api_base_url") ?? "https://agent-backend.thomas.md" } + set { UserDefaults.standard.set(newValue, forKey: "api_base_url") } + } + + private var jwtToken: String? { + get { UserDefaults.standard.string(forKey: "jwt_token") } + set { UserDefaults.standard.set(newValue, forKey: "jwt_token") } + } + + var isAuthenticated: Bool { + jwtToken != nil + } + + var authRequired: Bool = false + + + // MARK: - Authentication + + func login(password: String) async throws -> Bool { + struct LoginRequest: Encodable { + let password: String + } + + struct LoginResponse: Decodable { + let token: String + let exp: Int + } + + let response: LoginResponse = try await post("/api/auth/login", body: LoginRequest(password: password), authenticated: false) + jwtToken = response.token + return true + } + + func logout() { + jwtToken = nil + } + + func checkHealth() async throws -> Bool { + struct HealthResponse: Decodable { + let status: String + let authRequired: Bool + + enum CodingKeys: String, CodingKey { + case status + case authRequired = "auth_required" + } + } + + let response: HealthResponse = try await get("/api/health", authenticated: false) + authRequired = response.authRequired + return response.status == "ok" + } + + // MARK: - Missions + + func listMissions() async throws -> [Mission] { + try await get("/api/control/missions") + } + + func getMission(id: String) async throws -> Mission { + try await get("/api/control/missions/\(id)") + } + + func getCurrentMission() async throws -> Mission? { + try await get("/api/control/missions/current") + } + + func createMission() async throws -> Mission { + try await post("/api/control/missions", body: EmptyBody()) + } + + func loadMission(id: String) async throws -> Mission { + try await post("/api/control/missions/\(id)/load", body: EmptyBody()) + } + + func setMissionStatus(id: String, status: MissionStatus) async throws { + struct StatusRequest: Encodable { + let status: String + } + let _: EmptyResponse = try await post("/api/control/missions/\(id)/status", body: StatusRequest(status: status.rawValue)) + } + + // MARK: - Control + + func sendMessage(content: String) async throws -> (id: String, queued: Bool) { + struct MessageRequest: Encodable { + let content: String + } + + struct MessageResponse: Decodable { + let id: String + let queued: Bool + } + + let response: MessageResponse = try await post("/api/control/message", body: MessageRequest(content: content)) + return (response.id, response.queued) + } + + func cancelControl() async throws { + let _: EmptyResponse = try await post("/api/control/cancel", body: EmptyBody()) + } + + // MARK: - Tasks + + func listTasks() async throws -> [TaskState] { + try await get("/api/tasks") + } + + // MARK: - Runs + + func listRuns(limit: Int = 20, offset: Int = 0) async throws -> [Run] { + struct RunsResponse: Decodable { + let runs: [Run] + } + let response: RunsResponse = try await get("/api/runs?limit=\(limit)&offset=\(offset)") + return response.runs + } + + // MARK: - File System + + func listDirectory(path: String) async throws -> [FileEntry] { + try await get("/api/fs/list?path=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? path)") + } + + func createDirectory(path: String) async throws { + struct MkdirRequest: Encodable { + let path: String + } + let _: EmptyResponse = try await post("/api/fs/mkdir", body: MkdirRequest(path: path)) + } + + func deleteFile(path: String, recursive: Bool = false) async throws { + struct RmRequest: Encodable { + let path: String + let recursive: Bool + } + let _: EmptyResponse = try await post("/api/fs/rm", body: RmRequest(path: path, recursive: recursive)) + } + + func downloadURL(path: String) -> URL? { + guard var components = URLComponents(string: baseURL) else { return nil } + components.path = "/api/fs/download" + components.queryItems = [URLQueryItem(name: "path", value: path)] + return components.url + } + + func uploadFile(data: Data, fileName: String, directory: String) async throws -> String { + guard let url = URL(string: "\(baseURL)/api/fs/upload?path=\(directory.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? directory)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + if let token = jwtToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let boundary = UUID().uuidString + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = body + + let (responseData, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + if httpResponse.statusCode == 401 { + logout() + throw APIError.unauthorized + } + + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + throw APIError.httpError(httpResponse.statusCode, String(data: responseData, encoding: .utf8)) + } + + struct UploadResponse: Decodable { + let path: String + } + + let uploadResponse = try JSONDecoder().decode(UploadResponse.self, from: responseData) + return uploadResponse.path + } + + // MARK: - SSE Streaming + + func streamControl(onEvent: @escaping (String, [String: Any]) -> Void) -> Task { + Task { + guard let url = URL(string: "\(baseURL)/api/control/stream") else { return } + + var request = URLRequest(url: url) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + + if let token = jwtToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + do { + let (stream, _) = try await URLSession.shared.bytes(for: request) + + var buffer = "" + for try await byte in stream { + guard !Task.isCancelled else { break } + + if let char = String(bytes: [byte], encoding: .utf8) { + buffer.append(char) + + // Look for double newline (end of SSE event) + while let range = buffer.range(of: "\n\n") { + let eventString = String(buffer[.. Void) { + var eventType = "message" + var dataString = "" + + for line in eventString.split(separator: "\n", omittingEmptySubsequences: false) { + let lineStr = String(line) + if lineStr.hasPrefix("event:") { + eventType = String(lineStr.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } else if lineStr.hasPrefix("data:") { + dataString += String(lineStr.dropFirst(5)).trimmingCharacters(in: .whitespaces) + } + } + + guard !dataString.isEmpty else { return } + + do { + if let data = dataString.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + onEvent(eventType, json) + } + } catch { + // Ignore parse errors + } + } + + // MARK: - Private Helpers + + private struct EmptyBody: Encodable {} + private struct EmptyResponse: Decodable {} + + private func get(_ path: String, authenticated: Bool = true) async throws -> T { + guard let url = URL(string: "\(baseURL)\(path)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + if authenticated, let token = jwtToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + return try await execute(request) + } + + private func post(_ path: String, body: B, authenticated: Bool = true) async throws -> T { + guard let url = URL(string: "\(baseURL)\(path)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if authenticated, let token = jwtToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + request.httpBody = try JSONEncoder().encode(body) + + return try await execute(request) + } + + private func execute(_ request: URLRequest) async throws -> T { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + if httpResponse.statusCode == 401 { + logout() + throw APIError.unauthorized + } + + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + throw APIError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8)) + } + + // Handle empty responses + if data.isEmpty || (T.self == EmptyResponse.self) { + if let empty = EmptyResponse() as? T { + return empty + } + } + + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} + +enum APIError: LocalizedError { + case invalidURL + case invalidResponse + case unauthorized + case httpError(Int, String?) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response from server" + case .unauthorized: + return "Authentication required" + case .httpError(let code, let message): + return "HTTP \(code): \(message ?? "Unknown error")" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + } + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/GlassButton.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/GlassButton.swift new file mode 100644 index 0000000..54c6995 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/GlassButton.swift @@ -0,0 +1,248 @@ +// +// GlassButton.swift +// OpenAgentDashboard +// +// Glass morphism button components +// + +import SwiftUI + +struct GlassButton: View { + let title: String + let icon: String? + let action: () -> Void + + @State private var isPressed = false + + init(_ title: String, icon: String? = nil, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + } + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.2), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.06), radius: 8, y: 4) + .scaleEffect(isPressed ? 0.97 : 1) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { isPressed = true } + } + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.15)) { isPressed = false } + } + ) + } +} + +struct GlassPrimaryButton: View { + let title: String + let icon: String? + let action: () -> Void + var isLoading: Bool = false + var isDisabled: Bool = false + + @State private var isPressed = false + + init(_ title: String, icon: String? = nil, isLoading: Bool = false, isDisabled: Bool = false, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.isLoading = isLoading + self.isDisabled = isDisabled + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .tint(.white) + } else { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + } + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + LinearGradient( + colors: [Theme.accent, Theme.accent.opacity(0.85)], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6) + .scaleEffect(isPressed ? 0.97 : 1) + .opacity(isDisabled ? 0.5 : 1) + } + .buttonStyle(.plain) + .disabled(isLoading || isDisabled) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + guard !isLoading && !isDisabled else { return } + withAnimation(.easeInOut(duration: 0.1)) { isPressed = true } + } + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.15)) { isPressed = false } + } + ) + } +} + +struct GlassIconButton: View { + let icon: String + let action: () -> Void + var size: CGFloat = 44 + var tint: Color? = nil + + @State private var isPressed = false + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(tint ?? .primary) + .frame(width: size, height: size) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(.white.opacity(0.2), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.06), radius: 6, y: 3) + .scaleEffect(isPressed ? 0.92 : 1) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { isPressed = true } + } + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.15)) { isPressed = false } + } + ) + } +} + +struct GlassDestructiveButton: View { + let title: String + let icon: String? + let action: () -> Void + + @State private var isPressed = false + + init(_ title: String, icon: String? = nil, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + } + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + .foregroundStyle(Theme.error) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.error.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Theme.error.opacity(0.3), lineWidth: 0.5) + ) + .scaleEffect(isPressed ? 0.97 : 1) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { isPressed = true } + } + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.15)) { isPressed = false } + } + ) + } +} + +#Preview { + ZStack { + LinearGradient( + colors: [.orange.opacity(0.5), .pink.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 16) { + GlassButton("Secondary Button", icon: "message") { + print("Tapped") + } + + GlassPrimaryButton("Send Message", icon: "paperplane.fill") { + print("Tapped") + } + + GlassPrimaryButton("Loading...", isLoading: true) { + print("Tapped") + } + + GlassDestructiveButton("Delete", icon: "trash") { + print("Delete") + } + + HStack(spacing: 16) { + GlassIconButton(icon: "message.fill") { + print("Message") + } + + GlassIconButton(icon: "folder.fill") { + print("Folder") + } + + GlassIconButton(icon: "terminal.fill", action: { + print("Terminal") + }, tint: Theme.accent) + + GlassIconButton(icon: "xmark", action: { + print("Close") + }, size: 36) + } + } + .padding() + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/GlassCard.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/GlassCard.swift new file mode 100644 index 0000000..74d586f --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/GlassCard.swift @@ -0,0 +1,174 @@ +// +// GlassCard.swift +// OpenAgentDashboard +// +// Beautiful glass morphism card components with liquid glass effects +// + +import SwiftUI + +struct GlassCard: View { + let content: Content + var padding: CGFloat = 20 + var cornerRadius: CGFloat = 24 + + init( + padding: CGFloat = 20, + cornerRadius: CGFloat = 24, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.padding = padding + self.cornerRadius = cornerRadius + } + + var body: some View { + content + .padding(padding) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .shadow(color: .black.opacity(0.06), radius: 16, y: 8) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(.white.opacity(0.2), lineWidth: 0.5) + ) + } +} + +struct GlassCardLight: View { + let content: Content + var padding: CGFloat = 16 + var cornerRadius: CGFloat = 20 + + init( + padding: CGFloat = 16, + cornerRadius: CGFloat = 20, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.padding = padding + self.cornerRadius = cornerRadius + } + + var body: some View { + content + .padding(padding) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } +} + +struct GlassCardThick: View { + let content: Content + var padding: CGFloat = 20 + var cornerRadius: CGFloat = 24 + + init( + padding: CGFloat = 20, + cornerRadius: CGFloat = 24, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.padding = padding + self.cornerRadius = cornerRadius + } + + var body: some View { + content + .padding(padding) + .background(.thickMaterial) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .shadow(color: .black.opacity(0.08), radius: 20, y: 10) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(.white.opacity(0.25), lineWidth: 1) + ) + } +} + +/// Interactive glass card with hover/press states +struct InteractiveGlassCard: View { + let content: Content + var padding: CGFloat = 16 + var cornerRadius: CGFloat = 16 + let action: () -> Void + + @State private var isPressed = false + + init( + padding: CGFloat = 16, + cornerRadius: CGFloat = 16, + action: @escaping () -> Void, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.padding = padding + self.cornerRadius = cornerRadius + self.action = action + } + + var body: some View { + Button(action: action) { + content + .padding(padding) + .background(.ultraThinMaterial.opacity(isPressed ? 0.8 : 1)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(.white.opacity(isPressed ? 0.25 : 0.12), lineWidth: 0.5) + ) + .scaleEffect(isPressed ? 0.98 : 1) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { isPressed = true } + } + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.15)) { isPressed = false } + } + ) + } +} + +#Preview { + ZStack { + LinearGradient( + colors: [.indigo.opacity(0.6), .purple.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 20) { + GlassCard { + VStack(alignment: .leading, spacing: 8) { + Text("Glass Card") + .font(.headline) + Text("Beautiful translucent design") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + GlassCardLight { + Text("Light Glass Card") + .frame(maxWidth: .infinity) + } + + GlassCardThick { + Text("Thick Glass Card") + .frame(maxWidth: .infinity) + } + + InteractiveGlassCard(action: { print("Tapped") }) { + Text("Interactive Card - Tap me!") + .frame(maxWidth: .infinity) + } + } + .padding() + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/LoadingView.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/LoadingView.swift new file mode 100644 index 0000000..56b6242 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/LoadingView.swift @@ -0,0 +1,144 @@ +// +// LoadingView.swift +// OpenAgentDashboard +// +// Loading indicators and shimmer effects +// + +import SwiftUI + +struct LoadingView: View { + var message: String = "Loading..." + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + .tint(Theme.accent) + + Text(message) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ShimmerView: View { + @State private var isAnimating = false + + var body: some View { + LinearGradient( + colors: [ + Color.white.opacity(0.04), + Color.white.opacity(0.08), + Color.white.opacity(0.04) + ], + startPoint: .leading, + endPoint: .trailing + ) + .offset(x: isAnimating ? 300 : -300) + .animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating) + .onAppear { + isAnimating = true + } + } +} + +struct ShimmerRow: View { + var height: CGFloat = 16 + var width: CGFloat? = nil + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.06)) + .frame(width: width, height: height) + .overlay(ShimmerView()) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} + +struct ShimmerCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.06)) + .frame(width: 40, height: 40) + .overlay(ShimmerView()) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 6) { + ShimmerRow(height: 14, width: 120) + ShimmerRow(height: 12, width: 80) + } + } + + ShimmerRow(height: 12) + ShimmerRow(height: 12, width: 200) + } + .padding(16) + .background(Color.white.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var action: (() -> Void)? = nil + var actionLabel: String = "Try Again" + + var body: some View { + VStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(Theme.textTertiary) + + VStack(spacing: 8) { + Text(title) + .font(.title3.bold()) + .foregroundStyle(Theme.textPrimary) + + Text(message) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + } + + if let action = action { + Button(action: action) { + Text(actionLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Theme.accent.opacity(0.15)) + .clipShape(Capsule()) + } + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + VStack(spacing: 24) { + LoadingView() + .frame(height: 150) + + ShimmerCard() + .padding() + + EmptyStateView( + icon: "message.badge.filled.fill", + title: "No Messages", + message: "Start a conversation with the agent", + action: { print("Tapped") } + ) + .frame(height: 250) + } + .background(Theme.backgroundPrimary) +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift new file mode 100644 index 0000000..5b54ffb --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift @@ -0,0 +1,155 @@ +// +// StatusBadge.swift +// OpenAgentDashboard +// +// Status indicator badges with semantic colors +// + +import SwiftUI + +enum StatusType { + case pending + case running + case active + case completed + case failed + case cancelled + case idle + case error + case connected + case disconnected + case connecting + + var color: Color { + switch self { + case .pending, .idle: + return Theme.textMuted + case .running, .active, .connecting: + return Theme.accent + case .completed, .connected: + return Theme.success + case .failed, .error: + return Theme.error + case .cancelled, .disconnected: + return Theme.textTertiary + } + } + + var backgroundColor: Color { + color.opacity(0.15) + } + + var label: String { + switch self { + case .pending: return "Pending" + case .running: return "Running" + case .active: return "Active" + case .completed: return "Completed" + case .failed: return "Failed" + case .cancelled: return "Cancelled" + case .idle: return "Idle" + case .error: return "Error" + case .connected: return "Connected" + case .disconnected: return "Disconnected" + case .connecting: return "Connecting" + } + } + + var icon: String { + switch self { + case .pending: return "clock" + case .running, .connecting: return "arrow.trianglehead.2.clockwise" + case .active: return "circle.fill" + case .completed: return "checkmark.circle.fill" + case .failed, .error: return "xmark.circle.fill" + case .cancelled: return "slash.circle" + case .idle: return "moon.fill" + case .connected: return "wifi" + case .disconnected: return "wifi.slash" + } + } + + var shouldPulse: Bool { + switch self { + case .running, .active, .connecting: + return true + default: + return false + } + } +} + +struct StatusBadge: View { + let status: StatusType + var showIcon: Bool = true + var compact: Bool = false + + var body: some View { + HStack(spacing: compact ? 4 : 6) { + if showIcon { + Image(systemName: status.icon) + .font(.system(size: compact ? 10 : 12, weight: .medium)) + .symbolEffect(.pulse, options: status.shouldPulse ? .repeating : .nonRepeating) + } + Text(status.label) + .font(.system(size: compact ? 10 : 11, weight: .semibold)) + .textCase(.uppercase) + } + .foregroundStyle(status.color) + .padding(.horizontal, compact ? 8 : 10) + .padding(.vertical, compact ? 4 : 6) + .background(status.backgroundColor) + .clipShape(Capsule()) + } +} + +struct StatusDot: View { + let status: StatusType + var size: CGFloat = 8 + + var body: some View { + Circle() + .fill(status.color) + .frame(width: size, height: size) + .overlay { + if status.shouldPulse { + Circle() + .stroke(status.color.opacity(0.5), lineWidth: 2) + .scaleEffect(1.5) + .opacity(0.5) + } + } + } +} + +#Preview { + VStack(spacing: 16) { + HStack(spacing: 8) { + StatusBadge(status: .pending) + StatusBadge(status: .running) + StatusBadge(status: .completed) + } + + HStack(spacing: 8) { + StatusBadge(status: .failed) + StatusBadge(status: .cancelled) + StatusBadge(status: .active) + } + + HStack(spacing: 8) { + StatusBadge(status: .connected, compact: true) + StatusBadge(status: .disconnected, compact: true) + StatusBadge(status: .connecting, compact: true) + } + + Divider() + + HStack(spacing: 16) { + ForEach([StatusType.active, .completed, .failed, .idle], id: \.label) { status in + StatusDot(status: status) + } + } + } + .padding() + .background(Theme.backgroundPrimary) +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift new file mode 100644 index 0000000..d3427fa --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift @@ -0,0 +1,538 @@ +// +// ControlView.swift +// OpenAgentDashboard +// +// Chat interface for the AI agent with real-time streaming +// + +import SwiftUI + +struct ControlView: View { + @State private var messages: [ChatMessage] = [] + @State private var inputText = "" + @State private var runState: ControlRunState = .idle + @State private var queueLength = 0 + @State private var currentMission: Mission? + @State private var isLoading = true + @State private var streamTask: Task? + @State private var showMissionMenu = false + + @FocusState private var isInputFocused: Bool + + private let api = APIService.shared + + var body: some View { + ZStack { + // Background with subtle accent glow + Theme.backgroundPrimary.ignoresSafeArea() + + // Subtle radial gradients for liquid glass refraction + backgroundGlows + + VStack(spacing: 0) { + // Header + headerView + + // Messages + messagesView + + // Input area + inputView + } + } + .navigationBarTitleDisplayMode(.inline) + .task { + await loadCurrentMission() + startStreaming() + } + .onDisappear { + streamTask?.cancel() + } + } + + // MARK: - Background + + private var backgroundGlows: some View { + ZStack { + RadialGradient( + colors: [Theme.accent.opacity(0.08), .clear], + center: .topTrailing, + startRadius: 20, + endRadius: 400 + ) + .ignoresSafeArea() + .allowsHitTesting(false) + + RadialGradient( + colors: [Color.white.opacity(0.03), .clear], + center: .bottomLeading, + startRadius: 30, + endRadius: 500 + ) + .ignoresSafeArea() + .allowsHitTesting(false) + } + } + + // MARK: - Header + + private var headerView: some View { + HStack(spacing: 12) { + // Mission info + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(currentMission?.displayTitle ?? "Control") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + if let status = currentMission?.status { + StatusBadge(status: status.statusType, compact: true) + } + } + + HStack(spacing: 8) { + StatusDot(status: runState.statusType, size: 6) + Text(runState.label) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + + if queueLength > 0 { + Text("• Queue: \(queueLength)") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + } + + Spacer() + + // Mission menu + Menu { + Button { + Task { await createNewMission() } + } label: { + Label("New Mission", systemImage: "plus") + } + + if let mission = currentMission { + Divider() + + Button { + Task { await setMissionStatus(.completed) } + } label: { + Label("Mark Complete", systemImage: "checkmark.circle") + } + + Button(role: .destructive) { + Task { await setMissionStatus(.failed) } + } label: { + Label("Mark Failed", systemImage: "xmark.circle") + } + + if mission.status != .active { + Button { + Task { await setMissionStatus(.active) } + } label: { + Label("Reactivate", systemImage: "arrow.clockwise") + } + } + } + } label: { + GlassIconButton(icon: "ellipsis", action: {}, size: 36) + .allowsHitTesting(false) + } + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + } + + // MARK: - Messages + + private var messagesView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 16) { + if messages.isEmpty && !isLoading { + emptyStateView + } else if isLoading { + LoadingView(message: "Loading conversation...") + .frame(height: 200) + } else { + ForEach(messages) { message in + MessageBubble(message: message) + .id(message.id) + } + } + } + .padding() + } + .onChange(of: messages.count) { _, _ in + if let lastMessage = messages.last { + withAnimation { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "bubble.left.and.bubble.right.fill") + .font(.system(size: 48)) + .foregroundStyle(Theme.accent.opacity(0.6)) + + VStack(spacing: 8) { + Text("Start a Conversation") + .font(.title3.bold()) + .foregroundStyle(Theme.textPrimary) + + Text("Send a message to the AI agent to begin") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + } + } + .frame(maxHeight: .infinity) + .padding(40) + } + + // MARK: - Input + + private var inputView: some View { + VStack(spacing: 0) { + Divider() + .background(Theme.border) + + HStack(spacing: 12) { + // Text input + TextField("Message the agent...", text: $inputText, axis: .vertical) + .textFieldStyle(.plain) + .font(.body) + .lineLimit(1...5) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(isInputFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1) + ) + .focused($isInputFocused) + .onSubmit { + sendMessage() + } + + // Send/Stop button + if runState != .idle { + Button { + Task { await cancelRun() } + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(Theme.error) + .clipShape(Circle()) + } + } else { + Button { + sendMessage() + } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Theme.accent.opacity(0.5) : Theme.accent) + .clipShape(Circle()) + } + .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding() + .background(.ultraThinMaterial) + } + } + + // MARK: - Actions + + private func loadCurrentMission() async { + isLoading = true + defer { isLoading = false } + + do { + if let mission = try await api.getCurrentMission() { + currentMission = mission + messages = mission.history.enumerated().map { index, entry in + ChatMessage( + id: "\(mission.id)-\(index)", + type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), + content: entry.content + ) + } + } + } catch { + print("Failed to load mission: \(error)") + } + } + + private func createNewMission() async { + do { + let mission = try await api.createMission() + currentMission = mission + messages = [] + HapticService.success() + } catch { + print("Failed to create mission: \(error)") + HapticService.error() + } + } + + private func setMissionStatus(_ status: MissionStatus) async { + guard let mission = currentMission else { return } + + do { + try await api.setMissionStatus(id: mission.id, status: status) + currentMission?.status = status + HapticService.success() + } catch { + print("Failed to set status: \(error)") + HapticService.error() + } + } + + private func sendMessage() { + let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + + inputText = "" + HapticService.lightTap() + + Task { + do { + let _ = try await api.sendMessage(content: content) + } catch { + print("Failed to send message: \(error)") + HapticService.error() + } + } + } + + private func cancelRun() async { + do { + try await api.cancelControl() + HapticService.success() + } catch { + print("Failed to cancel: \(error)") + HapticService.error() + } + } + + private func startStreaming() { + streamTask = api.streamControl { eventType, data in + Task { @MainActor in + handleStreamEvent(type: eventType, data: data) + } + } + } + + private func handleStreamEvent(type: String, data: [String: Any]) { + switch type { + case "status": + if let state = data["state"] as? String { + runState = ControlRunState(rawValue: state) ?? .idle + } + if let queue = data["queue_len"] as? Int { + queueLength = queue + } + + case "user_message": + if let content = data["content"] as? String, + let id = data["id"] as? String { + let message = ChatMessage(id: id, type: .user, content: content) + messages.append(message) + } + + case "assistant_message": + if let content = data["content"] as? String, + let id = data["id"] as? String { + let success = data["success"] as? Bool ?? true + let costCents = data["cost_cents"] as? Int ?? 0 + let model = data["model"] as? String + + // Remove any incomplete thinking messages + messages.removeAll { $0.isThinking && !$0.thinkingDone } + + let message = ChatMessage( + id: id, + type: .assistant(success: success, costCents: costCents, model: model), + content: content + ) + messages.append(message) + } + + case "thinking": + if let content = data["content"] as? String { + let done = data["done"] as? Bool ?? false + + // Find existing thinking message or create new + if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) { + messages[index].content += "\n\n---\n\n" + content + if done { + messages[index] = ChatMessage( + id: messages[index].id, + type: .thinking(done: true, startTime: Date()), + content: messages[index].content + ) + } + } else if !done { + let message = ChatMessage( + id: "thinking-\(Date().timeIntervalSince1970)", + type: .thinking(done: false, startTime: Date()), + content: content + ) + messages.append(message) + } + } + + case "error": + if let errorMessage = data["message"] as? String { + let message = ChatMessage( + id: "error-\(Date().timeIntervalSince1970)", + type: .error, + content: errorMessage + ) + messages.append(message) + } + + default: + break + } + } +} + +// MARK: - Message Bubble + +private struct MessageBubble: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .top, spacing: 10) { + if message.isUser { + Spacer(minLength: 60) + userBubble + } else if message.isThinking { + thinkingBubble + Spacer(minLength: 60) + } else { + assistantBubble + Spacer(minLength: 60) + } + } + } + + private var userBubble: some View { + VStack(alignment: .trailing, spacing: 4) { + Text(message.content) + .font(.body) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.accent) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .clipShape( + .rect( + topLeadingRadius: 20, + bottomLeadingRadius: 20, + bottomTrailingRadius: 6, + topTrailingRadius: 20 + ) + ) + } + } + + private var assistantBubble: some View { + VStack(alignment: .leading, spacing: 8) { + // Status header for assistant messages + if case .assistant(let success, _, let model) = message.type { + HStack(spacing: 6) { + Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.caption2) + .foregroundStyle(success ? Theme.success : Theme.error) + + if let model = message.displayModel { + Text(model) + .font(.caption2.monospaced()) + .foregroundStyle(Theme.textTertiary) + } + + if let cost = message.costFormatted { + Text("•") + .foregroundStyle(Theme.textMuted) + Text(cost) + .font(.caption2.monospaced()) + .foregroundStyle(Theme.success) + } + } + } + + Text(message.content) + .font(.body) + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape( + .rect( + topLeadingRadius: 20, + bottomLeadingRadius: 6, + bottomTrailingRadius: 20, + topTrailingRadius: 20 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Theme.border, lineWidth: 0.5) + ) + } + } + + private var thinkingBubble: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.caption) + .foregroundStyle(Theme.accent) + .symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating) + + Text(message.thinkingDone ? "Thought" : "Thinking...") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Theme.accent.opacity(0.1)) + .clipShape(Capsule()) + + if !message.content.isEmpty { + Text(message.content) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + .lineLimit(message.thinkingDone ? 3 : nil) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.white.opacity(0.02)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + } +} + + +#Preview { + NavigationStack { + ControlView() + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift b/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift new file mode 100644 index 0000000..4a5e807 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift @@ -0,0 +1,407 @@ +// +// FilesView.swift +// OpenAgentDashboard +// +// Remote file explorer with SFTP-like functionality +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FilesView: View { + @State private var currentPath = "/root/context" + @State private var entries: [FileEntry] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var selectedEntry: FileEntry? + @State private var showingDeleteAlert = false + @State private var showingNewFolderAlert = false + @State private var newFolderName = "" + @State private var isImporting = false + + private let api = APIService.shared + + private var sortedEntries: [FileEntry] { + let dirs = entries.filter { $0.isDirectory }.sorted { $0.name < $1.name } + let files = entries.filter { !$0.isDirectory }.sorted { $0.name < $1.name } + return dirs + files + } + + private var breadcrumbs: [(name: String, path: String)] { + var crumbs: [(name: String, path: String)] = [("/", "/")] + var accumulated = "" + for part in currentPath.split(separator: "/") { + accumulated += "/" + part + crumbs.append((String(part), accumulated)) + } + return crumbs + } + + var body: some View { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 0) { + // Toolbar + toolbarView + + // Breadcrumb navigation + breadcrumbView + + // File list + if isLoading { + LoadingView(message: "Loading files...") + } else if let error = errorMessage { + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Failed to Load", + message: error, + action: { Task { await loadDirectory() } }, + actionLabel: "Retry" + ) + } else if sortedEntries.isEmpty { + EmptyStateView( + icon: "folder", + title: "Empty Folder", + message: "This folder is empty.\nDrag files here or tap Import." + ) + } else { + fileListView + } + } + } + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showingNewFolderAlert = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + + Button { + isImporting = true + } label: { + Label("Import Files", systemImage: "square.and.arrow.down") + } + + Divider() + + Button { + Task { await loadDirectory() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .alert("New Folder", isPresented: $showingNewFolderAlert) { + TextField("Folder name", text: $newFolderName) + Button("Cancel", role: .cancel) { + newFolderName = "" + } + Button("Create") { + Task { await createFolder() } + } + } + .alert("Delete \(selectedEntry?.name ?? "")?", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { await deleteSelected() } + } + } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + Task { await handleFileImport(result) } + } + .task { + await loadDirectory() + } + } + + // MARK: - Subviews + + private var toolbarView: some View { + HStack(spacing: 12) { + // Back button + Button { + goUp() + } label: { + Image(systemName: "chevron.up") + .font(.body.weight(.medium)) + .foregroundStyle(currentPath == "/" ? Theme.textMuted : Theme.textPrimary) + .frame(width: 36, height: 36) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + .disabled(currentPath == "/") + + // Quick nav buttons + quickNavButton(icon: "📥", label: "context", path: "/root/context") + quickNavButton(icon: "🔨", label: "work", path: "/root/work") + quickNavButton(icon: "🛠️", label: "tools", path: "/root/tools") + + Spacer() + + // Import button + Button { + isImporting = true + } label: { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.down") + Text("Import") + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Theme.accent.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(.horizontal) + .padding(.vertical, 10) + } + + private func quickNavButton(icon: String, label: String, path: String) -> some View { + Button { + navigateTo(path) + } label: { + HStack(spacing: 4) { + Text(icon) + .font(.caption) + Text(label) + .font(.caption.weight(.medium)) + } + .foregroundStyle(currentPath.hasPrefix(path) ? Theme.accent : Theme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.3) : Theme.border, lineWidth: 1) + ) + } + } + + private var breadcrumbView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, crumb in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(Theme.textMuted) + } + + Button { + navigateTo(crumb.path) + } label: { + Text(crumb.name) + .font(.caption.weight(index == breadcrumbs.count - 1 ? .semibold : .regular)) + .foregroundStyle(index == breadcrumbs.count - 1 ? Theme.textPrimary : Theme.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } + } + } + .padding(.horizontal) + } + .padding(.vertical, 8) + .background(Color.white.opacity(0.02)) + } + + private var fileListView: some View { + List { + ForEach(sortedEntries) { entry in + FileRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + if entry.isDirectory { + navigateTo(entry.path) + } else { + selectedEntry = entry + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + selectedEntry = entry + showingDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + + if entry.isFile { + Button { + downloadFile(entry) + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + .tint(Theme.accent) + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + // MARK: - Actions + + private func loadDirectory() async { + isLoading = true + errorMessage = nil + + do { + entries = try await api.listDirectory(path: currentPath) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func navigateTo(_ path: String) { + currentPath = path + Task { await loadDirectory() } + HapticService.selectionChanged() + } + + private func goUp() { + guard currentPath != "/" else { return } + var parts = currentPath.split(separator: "/") + parts.removeLast() + currentPath = parts.isEmpty ? "/" : "/" + parts.joined(separator: "/") + Task { await loadDirectory() } + HapticService.selectionChanged() + } + + private func createFolder() async { + guard !newFolderName.isEmpty else { return } + + let folderPath = currentPath.hasSuffix("/") + ? currentPath + newFolderName + : currentPath + "/" + newFolderName + + do { + try await api.createDirectory(path: folderPath) + newFolderName = "" + await loadDirectory() + HapticService.success() + } catch { + errorMessage = error.localizedDescription + HapticService.error() + } + } + + private func deleteSelected() async { + guard let entry = selectedEntry else { return } + + do { + try await api.deleteFile(path: entry.path, recursive: entry.isDirectory) + selectedEntry = nil + await loadDirectory() + HapticService.success() + } catch { + errorMessage = error.localizedDescription + HapticService.error() + } + } + + private func downloadFile(_ entry: FileEntry) { + guard let url = api.downloadURL(path: entry.path) else { return } + UIApplication.shared.open(url) + } + + private func handleFileImport(_ result: Result<[URL], Error>) async { + switch result { + case .success(let urls): + for url in urls { + guard url.startAccessingSecurityScopedResource() else { continue } + defer { url.stopAccessingSecurityScopedResource() } + + do { + let data = try Data(contentsOf: url) + let _ = try await api.uploadFile( + data: data, + fileName: url.lastPathComponent, + directory: currentPath + ) + } catch { + errorMessage = "Upload failed: \(error.localizedDescription)" + HapticService.error() + return + } + } + await loadDirectory() + HapticService.success() + + case .failure(let error): + errorMessage = error.localizedDescription + HapticService.error() + } + } +} + +// MARK: - File Row + +private struct FileRow: View { + let entry: FileEntry + + var body: some View { + HStack(spacing: 14) { + // Icon + Image(systemName: entry.icon) + .font(.title3) + .foregroundStyle(entry.isDirectory ? Theme.accent : Theme.textSecondary) + .frame(width: 40, height: 40) + .background(entry.isDirectory ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + // Name and details + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + HStack(spacing: 8) { + Text(entry.formattedSize) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + + Text(entry.kind) + .font(.caption) + .foregroundStyle(Theme.textMuted) + } + } + + Spacer() + + // Chevron for directories + if entry.isDirectory { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + } +} + +#Preview { + NavigationStack { + FilesView() + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift new file mode 100644 index 0000000..b38583c --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift @@ -0,0 +1,454 @@ +// +// HistoryView.swift +// OpenAgentDashboard +// +// Mission history list with search and filtering +// + +import SwiftUI + +struct HistoryView: View { + @State private var missions: [Mission] = [] + @State private var tasks: [TaskState] = [] + @State private var runs: [Run] = [] + @State private var isLoading = true + @State private var searchText = "" + @State private var selectedFilter: StatusFilter = .all + @State private var errorMessage: String? + + private let api = APIService.shared + + enum StatusFilter: String, CaseIterable { + case all = "All" + case active = "Active" + case completed = "Completed" + case failed = "Failed" + + var missionStatus: MissionStatus? { + switch self { + case .all: return nil + case .active: return .active + case .completed: return .completed + case .failed: return .failed + } + } + } + + private var filteredMissions: [Mission] { + missions.filter { mission in + // Filter by status + if let statusFilter = selectedFilter.missionStatus, mission.status != statusFilter { + return false + } + + // Filter by search + if !searchText.isEmpty { + let title = mission.title ?? "" + if !title.localizedCaseInsensitiveContains(searchText) { + return false + } + } + + return true + } + .sorted { ($0.updatedDate ?? Date.distantPast) > ($1.updatedDate ?? Date.distantPast) } + } + + var body: some View { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 0) { + // Search and filter + VStack(spacing: 12) { + // Search bar + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.textTertiary) + + TextField("Search missions...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + ) + + // Filter pills + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(StatusFilter.allCases, id: \.rawValue) { filter in + FilterPill( + title: filter.rawValue, + isSelected: selectedFilter == filter + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedFilter = filter + } + HapticService.selectionChanged() + } + } + } + } + } + .padding() + + // Content + if isLoading { + LoadingView(message: "Loading history...") + } else if let error = errorMessage { + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Failed to Load", + message: error, + action: { Task { await loadData() } }, + actionLabel: "Retry" + ) + } else if filteredMissions.isEmpty && tasks.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No History", + message: "Your missions will appear here" + ) + } else { + missionsList + } + } + } + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadData() + } + .refreshable { + await loadData() + } + } + + private var missionsList: some View { + ScrollView { + LazyVStack(spacing: 12) { + // Missions section + if !filteredMissions.isEmpty { + Section { + ForEach(filteredMissions) { mission in + NavigationLink(value: mission) { + MissionRow(mission: mission) + } + .buttonStyle(.plain) + } + } header: { + SectionHeader( + title: "Missions", + count: filteredMissions.count + ) + } + } + + // Active tasks section + if !tasks.isEmpty { + Section { + ForEach(tasks) { task in + TaskRow(task: task) + } + } header: { + SectionHeader( + title: "Active Tasks", + count: tasks.count + ) + } + } + + // Archived runs section + if !runs.isEmpty { + Section { + ForEach(runs) { run in + RunRow(run: run) + } + } header: { + SectionHeader( + title: "Archived Runs", + count: runs.count + ) + } + } + } + .padding() + } + .navigationDestination(for: Mission.self) { mission in + MissionDetailView(mission: mission) + } + } + + private func loadData() async { + isLoading = true + errorMessage = nil + + do { + async let missionsTask = api.listMissions() + async let tasksTask = api.listTasks() + async let runsTask = api.listRuns() + + let (missionsResult, tasksResult, runsResult) = try await (missionsTask, tasksTask, runsTask) + + missions = missionsResult + tasks = tasksResult + runs = runsResult + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +} + +// MARK: - Supporting Views + +private struct SectionHeader: View { + let title: String + let count: Int + + var body: some View { + HStack { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.textTertiary) + + Text("(\(count))") + .font(.caption) + .foregroundStyle(Theme.textMuted) + + Spacer() + } + .padding(.bottom, 4) + } +} + +private struct FilterPill: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(isSelected ? .white : Theme.textSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Theme.accent : Color.white.opacity(0.05)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(isSelected ? .clear : Theme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +private struct MissionRow: View { + let mission: Mission + + var body: some View { + HStack(spacing: 14) { + // Icon + Image(systemName: "target") + .font(.title3) + .foregroundStyle(Theme.accent) + .frame(width: 40, height: 40) + .background(Theme.accent.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(mission.displayTitle) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + HStack(spacing: 8) { + StatusBadge(status: mission.status.statusType, compact: true) + + Text("\(mission.history.count) messages") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + + Spacer() + + // Timestamp and chevron + VStack(alignment: .trailing, spacing: 4) { + if let date = mission.updatedDate { + Text(date.relativeFormatted) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted) + } + } + .padding(14) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.border, lineWidth: 0.5) + ) + } +} + +private struct TaskRow: View { + let task: TaskState + + var body: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(task.task) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + + HStack(spacing: 8) { + StatusBadge(status: task.status.statusType, compact: true) + + Text(task.displayModel) + .font(.caption.monospaced()) + .foregroundStyle(Theme.textTertiary) + + Text("•") + .foregroundStyle(Theme.textMuted) + + Text("\(task.iterations) iterations") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + + Spacer() + } + .padding(14) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.border, lineWidth: 0.5) + ) + } +} + +private struct RunRow: View { + let run: Run + + var body: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(run.inputText) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + + HStack(spacing: 8) { + if let date = run.createdDate { + Text(date.relativeFormatted) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + + Text("•") + .foregroundStyle(Theme.textMuted) + + Text(String(format: "$%.2f", run.costDollars)) + .font(.caption.monospaced()) + .foregroundStyle(Theme.success) + } + } + + Spacer() + } + .padding(14) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.border, lineWidth: 0.5) + ) + } +} + +// MARK: - Mission Detail View + +struct MissionDetailView: View { + let mission: Mission + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + // Header + VStack(alignment: .leading, spacing: 8) { + HStack { + StatusBadge(status: mission.status.statusType) + Spacer() + if let date = mission.updatedDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + + Text(mission.title ?? "Untitled Mission") + .font(.title3.bold()) + .foregroundStyle(Theme.textPrimary) + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + // Messages + if !mission.history.isEmpty { + ForEach(mission.history) { entry in + HStack(alignment: .top, spacing: 12) { + Image(systemName: entry.isUser ? "person.circle.fill" : "sparkles") + .foregroundStyle(entry.isUser ? Theme.accent : Theme.textSecondary) + + Text(entry.content) + .font(.body) + .foregroundStyle(Theme.textPrimary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial.opacity(entry.isUser ? 0.8 : 0.4)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + } + .padding() + } + .background(Theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("Mission") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Date Extension + +extension Date { + var relativeFormatted: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } +} + +#Preview { + NavigationStack { + HistoryView() + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift b/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift new file mode 100644 index 0000000..06878a6 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift @@ -0,0 +1,330 @@ +// +// TerminalView.swift +// OpenAgentDashboard +// +// SSH terminal with WebSocket connection +// + +import SwiftUI + +struct TerminalView: View { + @State private var terminalOutput: [TerminalLine] = [] + @State private var inputText = "" + @State private var connectionStatus: StatusType = .disconnected + @State private var webSocketTask: URLSessionWebSocketTask? + @State private var isConnecting = false + + @FocusState private var isInputFocused: Bool + + private let api = APIService.shared + + struct TerminalLine: Identifiable { + let id = UUID() + let text: String + let type: LineType + + enum LineType { + case input + case output + case error + case system + } + + var color: Color { + switch type { + case .input: return Theme.accent + case .output: return Theme.textPrimary + case .error: return Theme.error + case .system: return Theme.textTertiary + } + } + } + + var body: some View { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 0) { + // Connection status header + connectionHeader + + // Terminal output + terminalOutputView + + // Input field + inputView + } + } + .navigationTitle("Terminal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + if connectionStatus == .connected { + disconnect() + } else { + connect() + } + } label: { + Text(connectionStatus == .connected ? "Disconnect" : "Connect") + .font(.subheadline.weight(.medium)) + } + } + } + .onAppear { + connect() + } + .onDisappear { + disconnect() + } + } + + private var connectionHeader: some View { + HStack(spacing: 10) { + StatusDot(status: connectionStatus, size: 8) + + Text(connectionStatus.label) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + + Spacer() + + if connectionStatus != .connected { + Button { + connect() + } label: { + HStack(spacing: 6) { + if isConnecting { + ProgressView() + .scaleEffect(0.7) + } else { + Image(systemName: "arrow.clockwise") + } + Text("Reconnect") + } + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Theme.accent.opacity(0.15)) + .clipShape(Capsule()) + } + .disabled(isConnecting) + } + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + } + + private var terminalOutputView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(terminalOutput) { line in + Text(line.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(line.color) + .textSelection(.enabled) + .id(line.id) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.black.opacity(0.3)) + .onChange(of: terminalOutput.count) { _, _ in + if let lastLine = terminalOutput.last { + withAnimation { + proxy.scrollTo(lastLine.id, anchor: .bottom) + } + } + } + } + } + + private var inputView: some View { + VStack(spacing: 0) { + Divider() + .background(Theme.border) + + HStack(spacing: 12) { + Text("$") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Theme.accent) + + TextField("Enter command...", text: $inputText) + .textFieldStyle(.plain) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($isInputFocused) + .onSubmit { + sendCommand() + } + + Button { + sendCommand() + } label: { + Image(systemName: "return") + .font(.body) + .foregroundStyle(inputText.isEmpty ? Theme.textMuted : Theme.accent) + .frame(width: 36, height: 36) + .background(Theme.accent.opacity(inputText.isEmpty ? 0.1 : 0.2)) + .clipShape(Circle()) + } + .disabled(inputText.isEmpty || connectionStatus != .connected) + } + .padding() + .background(.ultraThinMaterial) + } + } + + // MARK: - WebSocket Connection + + private func connect() { + guard connectionStatus != .connected && !isConnecting else { return } + + isConnecting = true + connectionStatus = .connecting + addSystemLine("Connecting to \(api.baseURL)...") + + guard let wsURL = buildWebSocketURL() else { + addErrorLine("Invalid WebSocket URL") + connectionStatus = .error + isConnecting = false + return + } + + var request = URLRequest(url: wsURL) + + // Add auth via subprotocol if available + if let token = UserDefaults.standard.string(forKey: "jwt_token") { + request.setValue("openagent, jwt.\(token)", forHTTPHeaderField: "Sec-WebSocket-Protocol") + } + + webSocketTask = URLSession.shared.webSocketTask(with: request) + webSocketTask?.resume() + + // Start receiving messages + receiveMessages() + + // Send initial resize message after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if connectionStatus == .connecting { + connectionStatus = .connected + addSystemLine("Connected.") + } + isConnecting = false + sendResize(cols: 80, rows: 24) + } + } + + private func disconnect() { + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + connectionStatus = .disconnected + addSystemLine("Disconnected.") + } + + private func buildWebSocketURL() -> URL? { + guard var components = URLComponents(string: api.baseURL) else { return nil } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/api/console/ws" + return components.url + } + + private func receiveMessages() { + webSocketTask?.receive { [self] result in + switch result { + case .success(let message): + switch message { + case .string(let text): + DispatchQueue.main.async { + self.handleOutput(text) + } + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + DispatchQueue.main.async { + self.handleOutput(text) + } + } + @unknown default: + break + } + // Continue receiving + receiveMessages() + + case .failure(let error): + DispatchQueue.main.async { + if connectionStatus != .disconnected { + connectionStatus = .error + addErrorLine("Connection error: \(error.localizedDescription)") + } + } + } + } + } + + private func handleOutput(_ text: String) { + // Split by newlines and add each line + let lines = text.components(separatedBy: .newlines) + for line in lines { + if !line.isEmpty { + terminalOutput.append(TerminalLine(text: line, type: .output)) + } + } + + // Limit history + if terminalOutput.count > 1000 { + terminalOutput.removeFirst(terminalOutput.count - 1000) + } + } + + private func sendCommand() { + guard !inputText.isEmpty, connectionStatus == .connected else { return } + + let command = inputText + inputText = "" + + // Show the command in output + terminalOutput.append(TerminalLine(text: "$ \(command)", type: .input)) + + // Send to WebSocket + let message = ["t": "i", "d": command + "\n"] + if let data = try? JSONSerialization.data(withJSONObject: message), + let jsonString = String(data: data, encoding: .utf8) { + webSocketTask?.send(.string(jsonString)) { error in + if let error = error { + DispatchQueue.main.async { + addErrorLine("Send error: \(error.localizedDescription)") + } + } + } + } + + HapticService.lightTap() + } + + private func sendResize(cols: Int, rows: Int) { + let message = ["t": "r", "c": cols, "r": rows] as [String: Any] + if let data = try? JSONSerialization.data(withJSONObject: message), + let jsonString = String(data: data, encoding: .utf8) { + webSocketTask?.send(.string(jsonString)) { _ in } + } + } + + private func addSystemLine(_ text: String) { + terminalOutput.append(TerminalLine(text: text, type: .system)) + } + + private func addErrorLine(_ text: String) { + terminalOutput.append(TerminalLine(text: text, type: .error)) + } +} + +#Preview { + NavigationStack { + TerminalView() + } +} diff --git a/ios_dashboard/Package.swift b/ios_dashboard/Package.swift new file mode 100644 index 0000000..aae8398 --- /dev/null +++ b/ios_dashboard/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "OpenAgentDashboard", + platforms: [ + .iOS(.v18) + ], + products: [ + .library(name: "OpenAgentDashboard", targets: ["OpenAgentDashboard"]) + ], + targets: [ + .target( + name: "OpenAgentDashboard", + path: "OpenAgentDashboard" + ) + ] +) diff --git a/ios_dashboard/project.yml b/ios_dashboard/project.yml new file mode 100644 index 0000000..9034cd6 --- /dev/null +++ b/ios_dashboard/project.yml @@ -0,0 +1,38 @@ +name: OpenAgentDashboard +options: + bundleIdPrefix: md.thomas.openagent + deploymentTarget: + iOS: "18.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "6.0" + DEVELOPMENT_TEAM: "" + CODE_SIGN_IDENTITY: "" + CODE_SIGNING_REQUIRED: "NO" + CODE_SIGN_ENTITLEMENTS: "" + ENABLE_PREVIEWS: "YES" + +targets: + OpenAgentDashboard: + type: application + platform: iOS + deploymentTarget: "18.0" + sources: + - path: OpenAgentDashboard + excludes: + - "**/.DS_Store" + settings: + base: + INFOPLIST_FILE: OpenAgentDashboard/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: md.thomas.openagent.dashboard + PRODUCT_NAME: "Open Agent" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_EMIT_LOC_STRINGS: "YES" + GENERATE_INFOPLIST_FILE: "NO" + entitlements: + path: OpenAgentDashboard/OpenAgentDashboard.entitlements + properties: {} diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 803c63f..755f065 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -1,12 +1,19 @@ //! MCP runtime registry - manages connections and tool execution. +//! +//! Supports both HTTP and stdio transports: +//! - HTTP: JSON-RPC over HTTP POST requests +//! - Stdio: JSON-RPC over stdin/stdout with spawned child processes use std::collections::HashMap; use std::path::Path; +use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -use tokio::sync::RwLock; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; use super::config::McpConfigStore; @@ -15,14 +22,23 @@ use super::types::*; /// MCP protocol version we support const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; +/// Handle for a stdio MCP process +struct StdioProcess { + child: Child, + stdin: tokio::process::ChildStdin, + stdout_lines: Arc>>, +} + /// Runtime registry for MCP servers. pub struct McpRegistry { /// Persistent configuration store config_store: Arc, /// Runtime state for each MCP (keyed by ID) states: RwLock>, - /// HTTP client for MCP requests - client: reqwest::Client, + /// HTTP client for HTTP MCP requests + http_client: reqwest::Client, + /// Stdio processes for stdio MCPs (keyed by ID) + stdio_processes: RwLock>>>, /// Disabled tools (by name) disabled_tools: RwLock>, /// Request ID counter for JSON-RPC @@ -42,16 +58,17 @@ impl McpRegistry { } // Use very short timeouts to avoid blocking for too long - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .connect_timeout(Duration::from_millis(1000)) + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_millis(5000)) .build() .unwrap_or_default(); Self { config_store, states: RwLock::new(states), - client, + http_client, + stdio_processes: RwLock::new(HashMap::new()), disabled_tools: RwLock::new(std::collections::HashSet::new()), request_id: AtomicU64::new(1), } @@ -62,8 +79,8 @@ impl McpRegistry { self.request_id.fetch_add(1, Ordering::SeqCst) } - /// Send a JSON-RPC request to an MCP server - async fn send_jsonrpc( + /// Send a JSON-RPC request via HTTP + async fn send_jsonrpc_http( &self, endpoint: &str, method: &str, @@ -72,7 +89,7 @@ impl McpRegistry { let request = JsonRpcRequest::new(self.next_request_id(), method, params); let response = self - .client + .http_client .post(endpoint) .header("Content-Type", "application/json") .json(&request) @@ -94,8 +111,94 @@ impl McpRegistry { .ok_or_else(|| anyhow::anyhow!("No result in response")) } - /// Initialize connection with an MCP server - async fn initialize_mcp(&self, endpoint: &str) -> anyhow::Result { + /// Send a JSON-RPC request via stdio + async fn send_jsonrpc_stdio( + &self, + process: &Arc>, + method: &str, + params: Option, + ) -> anyhow::Result { + let request = JsonRpcRequest::new(self.next_request_id(), method, params); + let request_json = serde_json::to_string(&request)?; + + let mut proc = process.lock().await; + + // Write request to stdin + proc.stdin + .write_all(request_json.as_bytes()) + .await?; + proc.stdin.write_all(b"\n").await?; + proc.stdin.flush().await?; + + // Read response from stdout + let mut stdout = proc.stdout_lines.lock().await; + let mut line = String::new(); + + // Read with timeout + let read_result = tokio::time::timeout( + Duration::from_secs(30), + stdout.read_line(&mut line), + ) + .await; + + match read_result { + Ok(Ok(0)) => anyhow::bail!("MCP process closed stdout"), + Ok(Ok(_)) => { + let json_response: JsonRpcResponse = serde_json::from_str(&line)?; + + if let Some(error) = json_response.error { + anyhow::bail!("JSON-RPC error {}: {}", error.code, error.message); + } + + json_response + .result + .ok_or_else(|| anyhow::anyhow!("No result in response")) + } + Ok(Err(e)) => anyhow::bail!("Read error: {}", e), + Err(_) => anyhow::bail!("Timeout waiting for MCP response"), + } + } + + /// Spawn a stdio MCP process + async fn spawn_stdio_process( + &self, + command: &str, + args: &[String], + env: &HashMap, + ) -> anyhow::Result { + let mut cmd = Command::new(command); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Add environment variables + for (key, value) in env { + cmd.env(key, value); + } + + let mut child = cmd.spawn()?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to capture stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?; + + let stdout_lines = Arc::new(Mutex::new(BufReader::new(stdout))); + + Ok(StdioProcess { + child, + stdin, + stdout_lines, + }) + } + + /// Initialize connection with an MCP server (HTTP) + async fn initialize_mcp_http(&self, endpoint: &str) -> anyhow::Result { let params = InitializeParams { protocol_version: MCP_PROTOCOL_VERSION.to_string(), capabilities: ClientCapabilities::default(), @@ -106,14 +209,14 @@ impl McpRegistry { }; let result = self - .send_jsonrpc(endpoint, "initialize", Some(serde_json::to_value(params)?)) + .send_jsonrpc_http(endpoint, "initialize", Some(serde_json::to_value(params)?)) .await?; let init_result: InitializeResult = serde_json::from_value(result)?; // Send initialized notification (no response expected, but some servers require it) let _ = self - .client + .http_client .post(endpoint) .header("Content-Type", "application/json") .json(&serde_json::json!({ @@ -126,6 +229,41 @@ impl McpRegistry { Ok(init_result) } + /// Initialize connection with an MCP server (stdio) + async fn initialize_mcp_stdio( + &self, + process: &Arc>, + ) -> anyhow::Result { + let params = InitializeParams { + protocol_version: MCP_PROTOCOL_VERSION.to_string(), + capabilities: ClientCapabilities::default(), + client_info: ClientInfo { + name: "open-agent".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }; + + let result = self + .send_jsonrpc_stdio(process, "initialize", Some(serde_json::to_value(params)?)) + .await?; + + let init_result: InitializeResult = serde_json::from_value(result)?; + + // Send initialized notification + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let notification_json = serde_json::to_string(¬ification)?; + + let mut proc = process.lock().await; + let _ = proc.stdin.write_all(notification_json.as_bytes()).await; + let _ = proc.stdin.write_all(b"\n").await; + let _ = proc.stdin.flush().await; + + Ok(init_result) + } + /// List all MCP servers with their current state. pub async fn list(&self) -> Vec { self.states.read().await.values().cloned().collect() @@ -139,7 +277,14 @@ impl McpRegistry { /// Add a new MCP server. /// Note: This does NOT automatically attempt to connect. Use refresh() after adding. pub async fn add(&self, req: AddMcpRequest) -> anyhow::Result { - let mut config = McpServerConfig::new(req.name, req.endpoint); + let transport = req.effective_transport(); + + let mut config = match &transport { + McpTransport::Http { endpoint } => McpServerConfig::new(req.name.clone(), endpoint.clone()), + McpTransport::Stdio { command, args, env } => { + McpServerConfig::new_stdio(req.name.clone(), command.clone(), args.clone(), env.clone()) + } + }; config.description = req.description; // Save to persistent store @@ -161,6 +306,15 @@ impl McpRegistry { /// Remove an MCP server. pub async fn remove(&self, id: Uuid) -> anyhow::Result<()> { + // Kill stdio process if running + { + let mut processes = self.stdio_processes.write().await; + if let Some(process) = processes.remove(&id) { + let mut proc = process.lock().await; + let _ = proc.child.kill().await; + } + } + // Remove from persistent store self.config_store.remove(id).await?; @@ -193,6 +347,15 @@ impl McpRegistry { /// Disable an MCP server. pub async fn disable(&self, id: Uuid) -> anyhow::Result { + // Kill stdio process if running + { + let mut processes = self.stdio_processes.write().await; + if let Some(process) = processes.remove(&id) { + let mut proc = process.lock().await; + let _ = proc.child.kill().await; + } + } + // Update persistent config let config = self.config_store.disable(id).await?; @@ -235,15 +398,18 @@ impl McpRegistry { async fn update_state_success( &self, id: Uuid, - tool_names: Vec, + tool_descriptors: Vec, server_version: Option, ) { + let tool_names: Vec = tool_descriptors.iter().map(|t| t.name.clone()).collect(); + // Try up to 5 times with small delays to handle temporary lock contention for attempt in 0..5 { if let Ok(mut states) = self.states.try_write() { if let Some(state) = states.get_mut(&id) { - state.config.tools = tool_names; - state.config.version = server_version; + state.config.tools = tool_names.clone(); + state.config.tool_descriptors = tool_descriptors.clone(); + state.config.version = server_version.clone(); state.config.last_connected_at = Some(chrono::Utc::now()); state.status = McpStatus::Connected; state.error = None; @@ -270,10 +436,22 @@ impl McpRegistry { return Ok(state); } - let endpoint = state.config.endpoint.trim_end_matches('/').to_string(); + match &state.config.transport { + McpTransport::Http { endpoint } => { + self.refresh_http(id, endpoint.clone()).await + } + McpTransport::Stdio { command, args, env } => { + self.refresh_stdio(id, command.clone(), args.clone(), env.clone()).await + } + } + } + + /// Refresh an HTTP MCP server + async fn refresh_http(&self, id: Uuid, endpoint: String) -> anyhow::Result { + let endpoint = endpoint.trim_end_matches('/').to_string(); // Step 1: Initialize the MCP connection with JSON-RPC - let init_result = match self.initialize_mcp(&endpoint).await { + let init_result = match self.initialize_mcp_http(&endpoint).await { Ok(result) => result, Err(e) => { self.update_state_error(id, format!("Initialize failed: {}", e)) @@ -292,28 +470,127 @@ impl McpRegistry { .and_then(|s| s.version.clone()); // Step 2: List tools using JSON-RPC - match self.send_jsonrpc(&endpoint, "tools/list", None).await { + match self.send_jsonrpc_http(&endpoint, "tools/list", None).await { Ok(result) => { match serde_json::from_value::(result) { Ok(tools_response) => { - let tool_names: Vec = tools_response - .tools - .iter() - .map(|t| t.name.clone()) - .collect(); + let tool_descriptors = tools_response.tools; + let tool_names: Vec = tool_descriptors.iter().map(|t| t.name.clone()).collect(); // Update config with discovered tools let _ = self .config_store .update(id, |c| { c.tools = tool_names.clone(); + c.tool_descriptors = tool_descriptors.clone(); c.version = server_version.clone(); c.last_connected_at = Some(chrono::Utc::now()); }) .await; // Update runtime state - self.update_state_success(id, tool_names, server_version) + self.update_state_success(id, tool_descriptors, server_version) + .await; + } + Err(e) => { + self.update_state_error(id, format!("Failed to parse tools: {}", e)) + .await; + } + } + } + Err(e) => { + self.update_state_error(id, format!("tools/list failed: {}", e)) + .await; + } + } + + self.get(id) + .await + .ok_or_else(|| anyhow::anyhow!("MCP not found")) + } + + /// Refresh a stdio MCP server + async fn refresh_stdio( + &self, + id: Uuid, + command: String, + args: Vec, + env: HashMap, + ) -> anyhow::Result { + // Kill existing process if any + { + let mut processes = self.stdio_processes.write().await; + if let Some(process) = processes.remove(&id) { + let mut proc = process.lock().await; + let _ = proc.child.kill().await; + } + } + + // Spawn new process + let process = match self.spawn_stdio_process(&command, &args, &env).await { + Ok(p) => Arc::new(Mutex::new(p)), + Err(e) => { + self.update_state_error(id, format!("Failed to spawn process: {}", e)) + .await; + return self + .get(id) + .await + .ok_or_else(|| anyhow::anyhow!("MCP not found")); + } + }; + + // Store process handle + { + let mut processes = self.stdio_processes.write().await; + processes.insert(id, Arc::clone(&process)); + } + + // Step 1: Initialize the MCP connection + let init_result = match self.initialize_mcp_stdio(&process).await { + Ok(result) => result, + Err(e) => { + self.update_state_error(id, format!("Initialize failed: {}", e)) + .await; + // Clean up process + let mut processes = self.stdio_processes.write().await; + if let Some(process) = processes.remove(&id) { + let mut proc = process.lock().await; + let _ = proc.child.kill().await; + } + return self + .get(id) + .await + .ok_or_else(|| anyhow::anyhow!("MCP not found")); + } + }; + + // Extract server version if available + let server_version = init_result + .server_info + .as_ref() + .and_then(|s| s.version.clone()); + + // Step 2: List tools + match self.send_jsonrpc_stdio(&process, "tools/list", None).await { + Ok(result) => { + match serde_json::from_value::(result) { + Ok(tools_response) => { + let tool_descriptors = tools_response.tools; + let tool_names: Vec = tool_descriptors.iter().map(|t| t.name.clone()).collect(); + + // Update config with discovered tools + let _ = self + .config_store + .update(id, |c| { + c.tools = tool_names.clone(); + c.tool_descriptors = tool_descriptors.clone(); + c.version = server_version.clone(); + c.last_connected_at = Some(chrono::Utc::now()); + }) + .await; + + // Update runtime state + self.update_state_success(id, tool_descriptors, server_version) .await; } Err(e) => { @@ -367,19 +644,61 @@ impl McpRegistry { anyhow::bail!("MCP {} is not connected", state.config.name); } - let endpoint = state.config.endpoint.trim_end_matches('/'); - - // Use JSON-RPC tools/call method let params = serde_json::json!({ "name": tool_name, "arguments": arguments }); - let result = match self - .send_jsonrpc(endpoint, "tools/call", Some(params)) - .await - { - Ok(result) => result, + let result = match &state.config.transport { + McpTransport::Http { endpoint } => { + let endpoint = endpoint.trim_end_matches('/'); + self.send_jsonrpc_http(endpoint, "tools/call", Some(params)).await + } + McpTransport::Stdio { .. } => { + let processes = self.stdio_processes.read().await; + let process = processes + .get(&mcp_id) + .ok_or_else(|| anyhow::anyhow!("No stdio process for MCP {}", mcp_id))?; + self.send_jsonrpc_stdio(process, "tools/call", Some(params)).await + } + }; + + match result { + Ok(result) => { + let response: McpCallToolResponse = serde_json::from_value(result)?; + + // Increment counters + { + let mut states = self.states.write().await; + if let Some(state) = states.get_mut(&mcp_id) { + if response.is_error { + state.tool_errors += 1; + } else { + state.tool_calls += 1; + } + } + } + + if response.is_error { + let error_text = response + .content + .iter() + .filter_map(|c| c.text.as_deref()) + .collect::>() + .join("\n"); + anyhow::bail!("Tool error: {}", error_text); + } + + // Combine text content + let output = response + .content + .iter() + .filter_map(|c| c.text.as_deref()) + .collect::>() + .join("\n"); + + Ok(output) + } Err(e) => { // Increment error counter let mut states = self.states.write().await; @@ -388,41 +707,7 @@ impl McpRegistry { } anyhow::bail!("Tool call failed: {}", e); } - }; - - let response: McpCallToolResponse = serde_json::from_value(result)?; - - // Increment counters - { - let mut states = self.states.write().await; - if let Some(state) = states.get_mut(&mcp_id) { - if response.is_error { - state.tool_errors += 1; - } else { - state.tool_calls += 1; - } - } } - - if response.is_error { - let error_text = response - .content - .iter() - .filter_map(|c| c.text.as_deref()) - .collect::>() - .join("\n"); - anyhow::bail!("Tool error: {}", error_text); - } - - // Combine text content - let output = response - .content - .iter() - .filter_map(|c| c.text.as_deref()) - .collect::>() - .join("\n"); - - Ok(output) } /// List all tools from all connected MCPs. @@ -433,13 +718,13 @@ impl McpRegistry { let mut tools = Vec::new(); for state in states.values() { if state.config.enabled && state.status == McpStatus::Connected { - for tool_name in &state.config.tools { + for descriptor in &state.config.tool_descriptors { tools.push(McpTool { - name: tool_name.clone(), - description: String::new(), // Would need to store this from discovery - parameters_schema: serde_json::json!({}), + name: descriptor.name.clone(), + description: descriptor.description.clone(), + parameters_schema: descriptor.input_schema.clone(), mcp_id: state.config.id, - enabled: !disabled.contains(tool_name), + enabled: !disabled.contains(&descriptor.name), }); } } diff --git a/src/mcp/types.rs b/src/mcp/types.rs index ec19c87..87cbcee 100644 --- a/src/mcp/types.rs +++ b/src/mcp/types.rs @@ -3,6 +3,30 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Transport type for MCP server communication. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpTransport { + /// HTTP JSON-RPC transport (server must be running and listening) + Http { endpoint: String }, + /// Stdio transport (spawn process, communicate via stdin/stdout) + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::HashMap, + }, +} + +impl Default for McpTransport { + fn default() -> Self { + McpTransport::Http { + endpoint: "http://127.0.0.1:3000".to_string(), + } + } +} + /// Status of an MCP server connection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -129,7 +153,10 @@ pub struct McpServerConfig { pub id: Uuid, /// Human-readable name (e.g., "Supabase", "Browser Extension") pub name: String, - /// Server endpoint URL (e.g., "http://127.0.0.1:4011") + /// Transport configuration (HTTP or stdio) + pub transport: McpTransport, + /// Server endpoint URL (e.g., "http://127.0.0.1:4011") - DEPRECATED, use transport + #[serde(default, skip_serializing_if = "String::is_empty")] pub endpoint: String, /// Optional description pub description: Option, @@ -140,6 +167,9 @@ pub struct McpServerConfig { /// Tool names exposed by this MCP (populated after connection) #[serde(default)] pub tools: Vec, + /// Tool descriptors with full metadata (name, description, schema) + #[serde(default)] + pub tool_descriptors: Vec, /// When this MCP was added pub created_at: chrono::DateTime, /// Last time we successfully connected @@ -147,20 +177,52 @@ pub struct McpServerConfig { } impl McpServerConfig { - /// Create a new MCP server configuration. + /// Create a new MCP server configuration with HTTP transport. pub fn new(name: String, endpoint: String) -> Self { Self { id: Uuid::new_v4(), name, - endpoint, + transport: McpTransport::Http { endpoint: endpoint.clone() }, + endpoint, // Keep for backwards compat description: None, enabled: true, version: None, tools: Vec::new(), + tool_descriptors: Vec::new(), created_at: chrono::Utc::now(), last_connected_at: None, } } + + /// Create a new MCP server configuration with stdio transport. + pub fn new_stdio( + name: String, + command: String, + args: Vec, + env: std::collections::HashMap, + ) -> Self { + Self { + id: Uuid::new_v4(), + name, + transport: McpTransport::Stdio { command, args, env }, + endpoint: String::new(), + description: None, + enabled: true, + version: None, + tools: Vec::new(), + tool_descriptors: Vec::new(), + created_at: chrono::Utc::now(), + last_connected_at: None, + } + } + + /// Get the effective endpoint (for backwards compat) + pub fn effective_endpoint(&self) -> Option<&str> { + match &self.transport { + McpTransport::Http { endpoint } => Some(endpoint.as_str()), + McpTransport::Stdio { .. } => None, + } + } } /// Runtime state of an MCP server (not persisted). @@ -215,15 +277,34 @@ pub struct McpTool { #[derive(Debug, Clone, Deserialize)] pub struct AddMcpRequest { pub name: String, - pub endpoint: String, + /// HTTP endpoint (for backwards compat, use transport instead) + #[serde(default)] + pub endpoint: Option, + /// Transport configuration (preferred) + #[serde(default)] + pub transport: Option, pub description: Option, } +impl AddMcpRequest { + /// Get the effective transport from the request + pub fn effective_transport(&self) -> McpTransport { + if let Some(transport) = &self.transport { + transport.clone() + } else if let Some(endpoint) = &self.endpoint { + McpTransport::Http { endpoint: endpoint.clone() } + } else { + McpTransport::default() + } + } +} + /// Request to update an MCP server. #[derive(Debug, Clone, Deserialize)] pub struct UpdateMcpRequest { pub name: Option, pub endpoint: Option, + pub transport: Option, pub description: Option, pub enabled: Option, } @@ -235,7 +316,7 @@ pub struct McpToolsResponse { } /// Tool descriptor from MCP server. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpToolDescriptor { pub name: String, #[serde(default)]