diff --git a/Sources/HermesDesktop/App/AppState.swift b/Sources/HermesDesktop/App/AppState.swift index 6642720..c78f470 100644 --- a/Sources/HermesDesktop/App/AppState.swift +++ b/Sources/HermesDesktop/App/AppState.swift @@ -19,7 +19,7 @@ final class AppState: ObservableObject { @Published var isRefreshingOverview = false @Published var activeConnectionID: UUID? @Published var selectedSessionID: String? - @Published var selectedSessionDetailMode: SessionDetailMode = .transcript + @Published var selectedSessionDetailMode: SessionDetailMode = .chat @Published private(set) var sessionTUITerminal: SessionTUITerminal? @Published var sessions: [SessionSummary] = [] @Published var sessionMessages: [SessionMessage] = [] @@ -35,6 +35,7 @@ final class AppState: ObservableObject { @Published var liveToolActivityCards: [HermesToolActivityCard] = [] @Published var sessionPromptCards: [HermesPromptCard] = [] @Published var sessionCompactionNotice: SessionCompactionNotice? + @Published var workspaceSessionActiveRun: WorkspaceSessionActiveRun? @Published private(set) var nativeChatBootstrapStatus: HermesChatBootstrapStatus? @Published var hasMoreSessions = false @Published var totalSessionsCount = 0 @@ -47,6 +48,21 @@ final class AppState: ObservableObject { @Published var usageError: String? @Published var isLoadingUsage = false @Published var isRefreshingUsage = false + @Published var caelWorkspaceStatus: CaelWorkspaceStatus? + @Published var caelIntegrationStatus: CaelIntegrationStatus? + @Published var caelProviderUsageLimits: CaelProviderUsageLimits? + @Published var caelN8nGovernance: CaelN8nGovernanceStatus? + @Published var caelN8nGovernanceError: String? + @Published var caelCommandCenterSummary: CaelCommandCenterSummary? + @Published var caelCommandCenterSections: CaelCommandCenterSectionsSnapshot? + @Published var caelCommandCenterWarnings: [String] = [] + @Published var caelCommandCenterCacheNotice: String? + @Published var caelWorkspaceError: String? + @Published var caelProviderUsageError: String? + @Published var isLoadingCaelWorkspace = false + @Published var isRefreshingCaelWorkspace = false + @Published var isLoadingCaelProviderUsage = false + @Published var isRefreshingCaelProviderUsage = false @Published var selectedSkillID: String? @Published var skills: [SkillSummary] = [] @Published var selectedSkillDetail: SkillDetail? @@ -86,7 +102,18 @@ final class AppState: ObservableObject { @Published var workspaceFileDocuments: [String: FileEditorDocument] = [:] @Published var workspaceFileBrowserListing: RemoteDirectoryListing? @Published var workspaceFileBrowserError: String? + @Published var workspaceFileBrowserNotice: String? @Published var isLoadingWorkspaceFileBrowser = false + @Published var workspacePreviewFile: WorkspacePreviewFile? + @Published var workspacePreviewError: String? + @Published var isLoadingWorkspacePreview = false + @Published var isUploadingWorkspaceFile = false + @Published var toolArtifacts: [ToolArtifactSummary] = [] + @Published var selectedToolArtifactID: String? + @Published var selectedToolArtifactDetail: ToolArtifactDetail? + @Published var toolArtifactsError: String? + @Published var isLoadingToolArtifacts = false + @Published var isLoadingToolArtifactDetail = false @Published var pendingSectionSelection: AppSection? @Published var showDiscardChangesAlert = false @Published var pendingNewConnectionEditorRequestID: UUID? @@ -101,6 +128,8 @@ final class AppState: ObservableObject { let sessionBrowserService: SessionBrowserService let hermesChatService: HermesChatService let usageBrowserService: UsageBrowserService + let caelWorkspaceAPIService: CaelWorkspaceAPIService + let caelCommandCenterSnapshotStore: CaelCommandCenterSnapshotStore let skillBrowserService: SkillBrowserService let cronBrowserService: CronBrowserService let kanbanBrowserService: KanbanBrowserService @@ -121,12 +150,15 @@ final class AppState: ObservableObject { private let automaticUpdateCheckInterval: TimeInterval = 24 * 60 * 60 private var statusTask: Task? private var sessionTranscriptPollingTask: Task? + private var acceptedWorkspaceSessionMonitorTask: Task? + private var acceptedWorkspaceChatEventsTask: Task? private var gatewayChatService: HermesGatewayChatService? private var gatewayEventsTask: Task? private var nativeChatStatusWorkspaceScopeFingerprint: String? private var gatewayWorkspaceScopeFingerprint: String? private var gatewaySessionID: String? private var activeGatewayAssistantMessageID: String? + private var hasAcceptedNativeTurnInFlight = false private var activeNativeTurnResult: Bool? private var activeNativeTurnCompletion: CheckedContinuation? private var cancellables = Set() @@ -150,6 +182,8 @@ final class AppState: ObservableObject { self.sessionBrowserService = SessionBrowserService(sshTransport: sshTransport) self.hermesChatService = HermesChatService(sshTransport: sshTransport) self.usageBrowserService = UsageBrowserService(sshTransport: sshTransport) + self.caelWorkspaceAPIService = CaelWorkspaceAPIService(sshTransport: sshTransport) + self.caelCommandCenterSnapshotStore = CaelCommandCenterSnapshotStore(paths: paths) self.skillBrowserService = SkillBrowserService(sshTransport: sshTransport) self.cronBrowserService = CronBrowserService(sshTransport: sshTransport) self.kanbanBrowserService = KanbanBrowserService(sshTransport: sshTransport) @@ -197,6 +231,10 @@ final class AppState: ObservableObject { kanbanBoards.first(where: { $0.slug == selectedKanbanBoardSlug }) } + var isWorkspaceKanbanBoardSelected: Bool { + selectedKanbanBoardSlug == KanbanProject.workspaceTasksSlug + } + var canonicalWorkspaceFileReferences: [WorkspaceFileReference] { guard let activeConnection else { return [] } @@ -259,10 +297,13 @@ final class AppState: ObservableObject { var canRefreshCurrentSection: Bool { guard activeConnection != nil else { return false } + if selectedSection.isCommandCenterMirrorSection { + return !isLoadingCaelWorkspace && !isRefreshingCaelWorkspace + } switch selectedSection { case .overview: - return !isRefreshingOverview && !isBusy + return !isLoadingCaelWorkspace && !isRefreshingCaelWorkspace case .sessions: return !isLoadingSessions && !isRefreshingSessions case .workflows: @@ -272,10 +313,10 @@ final class AppState: ObservableObject { case .kanban: return !isLoadingKanbanBoards && !isLoadingKanbanBoard && !isRefreshingKanbanBoard case .usage: - return !isLoadingUsage && !isRefreshingUsage + return !isLoadingUsage && !isRefreshingUsage && !isLoadingCaelProviderUsage && !isRefreshingCaelProviderUsage case .skills: return !isLoadingSkills && !isRefreshingSkills - case .connections, .files, .terminal: + case .connections, .files, .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp, .profiles, .terminal: return false } } @@ -292,7 +333,7 @@ final class AppState: ObservableObject { switch selectedSection { case .sessions, .workflows, .cronjobs, .kanban, .skills: return true - case .connections, .overview, .files, .usage, .terminal: + case .connections, .overview, .files, .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .usage, .memory, .integrations, .mcp, .profiles, .terminal: return false } } @@ -361,10 +402,14 @@ final class AppState: ObservableObject { func refreshCurrentSectionFromCommand() async { guard canRefreshCurrentSection else { return } + if selectedSection.isCommandCenterMirrorSection { + await refreshCaelWorkspace() + return + } switch selectedSection { case .overview: - await refreshOverview(manual: true) + await refreshCaelWorkspace() case .sessions: await refreshSessions(query: sessionSearchQuery) case .workflows: @@ -375,9 +420,10 @@ final class AppState: ObservableObject { await refreshKanbanBoard() case .usage: await refreshUsage() + await refreshCaelProviderUsage() case .skills: await refreshSkills() - case .connections, .files, .terminal: + case .connections, .files, .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp, .profiles, .terminal: break } } @@ -445,9 +491,12 @@ final class AppState: ObservableObject { func connect(to profile: ConnectionProfile) { let isSwitchingConnection = activeConnection?.workspaceScopeFingerprint != profile.workspaceScopeFingerprint + let isSwitchingCaelWorkspace = activeConnection?.commandCenterClientFingerprint != profile.commandCenterClientFingerprint if isSwitchingConnection { resetWorkspaceStateForConnectionChange() + } else if isSwitchingCaelWorkspace { + resetCaelWorkspaceState() } activeConnectionID = profile.id @@ -468,6 +517,7 @@ final class AppState: ObservableObject { let previous = connectionStore.connections.first(where: { $0.id == normalized.id }) let isActiveConnection = activeConnectionID == normalized.id let isChangingWorkspaceScope = previous?.workspaceScopeFingerprint != normalized.workspaceScopeFingerprint + let isChangingCaelWorkspaceScope = previous?.commandCenterClientFingerprint != normalized.commandCenterClientFingerprint if isActiveConnection && isChangingWorkspaceScope && hasUnsavedFileChanges { activeAlert = AppAlert( @@ -480,14 +530,22 @@ final class AppState: ObservableObject { connectionStore.upsert(normalized) guard isActiveConnection else { return } - guard isChangingWorkspaceScope else { return } + if isChangingWorkspaceScope { + resetWorkspaceStateForConnectionChange() + selectedSection = .overview + setStatusMessage(L10n.string("Refreshing %@…", normalized.label)) - resetWorkspaceStateForConnectionChange() - selectedSection = .overview - setStatusMessage(L10n.string("Refreshing %@…", normalized.label)) + Task { + await prepareWorkspaceForActiveConnection() + } + } else if isChangingCaelWorkspaceScope { + resetCaelWorkspaceState() + setStatusMessage(L10n.string("Refreshing Cael Workspace for %@…", normalized.label)) - Task { - await prepareWorkspaceForActiveConnection() + Task { + await loadCaelWorkspace(forceRefresh: true) + await loadCaelProviderUsage(forceRefresh: true) + } } } @@ -614,6 +672,20 @@ final class AppState: ObservableObject { isRefreshingUsage = false } + func refreshCaelWorkspace() async { + guard !isLoadingCaelWorkspace, !isRefreshingCaelWorkspace else { return } + isRefreshingCaelWorkspace = true + await loadCaelWorkspace(forceRefresh: true) + isRefreshingCaelWorkspace = false + } + + func refreshCaelProviderUsage() async { + guard !isLoadingCaelProviderUsage, !isRefreshingCaelProviderUsage else { return } + isRefreshingCaelProviderUsage = true + await loadCaelProviderUsage(forceRefresh: true) + isRefreshingCaelProviderUsage = false + } + func refreshSkills() async { guard !isLoadingSkills, !isRefreshingSkills else { return } isRefreshingSkills = true @@ -671,9 +743,9 @@ final class AppState: ObservableObject { setDocument(document) do { - let snapshot = try await fileEditorService.read( - remotePath: reference.remotePath, - connection: profile + let snapshot = try await caelWorkspaceAPIService.readWorkspaceFile( + connection: profile, + path: reference.remotePath ) guard isActiveWorkspace(profile) else { return } document.content = snapshot.content @@ -712,11 +784,11 @@ final class AppState: ObservableObject { setDocument(document) do { - let saveResult = try await fileEditorService.write( - remotePath: reference.remotePath, + let saveResult = try await caelWorkspaceAPIService.writeWorkspaceFile( + connection: profile, + path: reference.remotePath, content: document.content, - expectedContentHash: document.remoteContentHash, - connection: profile + expectedContentHash: document.remoteContentHash ) guard isActiveWorkspace(profile) else { return } document.originalContent = document.content @@ -832,10 +904,11 @@ final class AppState: ObservableObject { workspaceFileBrowserError = nil do { - let listing = try await fileEditorService.listDirectory( - remotePath: browsePath, - hermesHome: overview?.hermesHome ?? profile.remoteHermesHomePath, - connection: profile + let listing = try await caelWorkspaceAPIService.listWorkspaceFiles( + connection: profile, + path: browsePath, + maxDepth: 0, + maxEntries: 500 ) guard isActiveWorkspace(profile) else { return } workspaceFileBrowserListing = listing @@ -844,10 +917,244 @@ final class AppState: ObservableObject { guard isActiveWorkspace(profile) else { return } isLoadingWorkspaceFileBrowser = false workspaceFileBrowserError = error.localizedDescription + workspaceFileBrowserNotice = nil setStatusMessage(L10n.string("Unable to browse remote files")) } } + func createWorkspaceDirectory(path: String) async { + guard let profile = activeConnection else { return } + let targetPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !targetPath.isEmpty else { return } + + do { + _ = try await caelWorkspaceAPIService.makeWorkspaceDirectory(connection: profile, path: targetPath) + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserNotice = L10n.string("Folder created") + setStatusMessage(L10n.string("Folder created")) + await browseWorkspaceDirectory(path: workspaceFileBrowserListing?.displayPath ?? workspaceFileBrowserDefaultPath) + } catch { + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserError = error.localizedDescription + setStatusMessage(L10n.string("Unable to create folder")) + } + } + + func renameWorkspacePath(from sourcePath: String, to destinationPath: String) async { + guard let profile = activeConnection else { return } + let source = sourcePath.trimmingCharacters(in: .whitespacesAndNewlines) + let destination = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty, !destination.isEmpty, source != destination else { return } + + do { + _ = try await caelWorkspaceAPIService.renameWorkspacePath(connection: profile, from: source, to: destination) + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserNotice = L10n.string("Renamed to %@", destination) + setStatusMessage(L10n.string("File renamed")) + await browseWorkspaceDirectory(path: workspaceFileBrowserListing?.displayPath ?? workspaceFileBrowserDefaultPath) + } catch { + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserError = error.localizedDescription + setStatusMessage(L10n.string("Unable to rename path")) + } + } + + func deleteWorkspacePath(path: String) async { + guard let profile = activeConnection else { return } + let targetPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !targetPath.isEmpty else { return } + + do { + try await caelWorkspaceAPIService.deleteWorkspacePath(connection: profile, path: targetPath) + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserNotice = L10n.string("Deleted %@", targetPath) + setStatusMessage(L10n.string("Path deleted")) + await browseWorkspaceDirectory(path: workspaceFileBrowserListing?.displayPath ?? workspaceFileBrowserDefaultPath) + } catch { + guard isActiveWorkspace(profile) else { return } + workspaceFileBrowserError = error.localizedDescription + setStatusMessage(L10n.string("Unable to delete path")) + } + } + + func uploadWorkspaceFile(localFileURL: URL, to targetPath: String) async { + guard let profile = activeConnection else { return } + let target = targetPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { return } + + isUploadingWorkspaceFile = true + workspaceFileBrowserError = nil + workspaceFileBrowserNotice = nil + + do { + let didAccess = localFileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + localFileURL.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: localFileURL) + guard data.count <= WorkspaceFileLimits.maxDesktopUploadBytes else { + throw SSHTransportError.invalidResponse( + L10n.string( + "Desktop uploads are limited to %@.", + WorkspaceFileLimits.decimalMegabytes(for: Int64(WorkspaceFileLimits.maxDesktopUploadBytes)) + ) + ) + } + + let result = try await caelWorkspaceAPIService.uploadWorkspaceFile( + connection: profile, + targetPath: target, + fileName: localFileURL.lastPathComponent, + contentBase64: data.base64EncodedString() + ) + guard isActiveWorkspace(profile) else { return } + isUploadingWorkspaceFile = false + let uploadedPath = result.path ?? localFileURL.lastPathComponent + let sizeText = result.size.map { ByteCountFormatter.string(fromByteCount: Int64($0), countStyle: .file) } + workspaceFileBrowserNotice = sizeText.map { L10n.string("Uploaded %@ (%@)", uploadedPath, $0) } ?? + L10n.string("Uploaded %@", uploadedPath) + setStatusMessage(L10n.string("%@ uploaded", uploadedPath)) + await browseWorkspaceDirectory(path: workspaceFileBrowserListing?.displayPath ?? workspaceFileBrowserDefaultPath) + } catch { + guard isActiveWorkspace(profile) else { return } + isUploadingWorkspaceFile = false + workspaceFileBrowserError = error.localizedDescription + setStatusMessage(L10n.string("Unable to upload file")) + } + } + + func previewWorkspacePath(_ path: String) async { + guard let profile = activeConnection else { return } + let targetPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !targetPath.isEmpty else { return } + + isLoadingWorkspacePreview = true + workspacePreviewError = nil + workspacePreviewFile = nil + + do { + let preview = try await caelWorkspaceAPIService.loadPreviewFile(connection: profile, path: targetPath) + guard isActiveWorkspace(profile) else { return } + workspacePreviewFile = preview + isLoadingWorkspacePreview = false + setStatusMessage(L10n.string("Preview loaded")) + } catch { + guard isActiveWorkspace(profile) else { return } + isLoadingWorkspacePreview = false + workspacePreviewError = error.localizedDescription + setStatusMessage(L10n.string("Unable to preview file")) + } + } + + func loadToolArtifacts(resetSelection: Bool = false) async { + guard let profile = activeConnection else { return } + if isLoadingToolArtifacts { return } + + isLoadingToolArtifacts = true + toolArtifactsError = nil + + do { + let artifacts = try await caelWorkspaceAPIService.loadToolArtifacts(connection: profile, limit: 100) + guard isActiveWorkspace(profile) else { return } + toolArtifacts = artifacts + if resetSelection || selectedToolArtifactID == nil || !artifacts.contains(where: { $0.id == selectedToolArtifactID }) { + selectedToolArtifactID = artifacts.first?.id + selectedToolArtifactDetail = nil + } + isLoadingToolArtifacts = false + if let selectedToolArtifactID { + await loadToolArtifactDetail(id: selectedToolArtifactID) + } + } catch { + guard isActiveWorkspace(profile) else { return } + isLoadingToolArtifacts = false + toolArtifactsError = error.localizedDescription + setStatusMessage(L10n.string("Unable to load artifacts")) + } + } + + func selectToolArtifact(_ artifactID: String) async { + selectedToolArtifactID = artifactID + await loadToolArtifactDetail(id: artifactID) + } + + func loadToolArtifactDetail(id artifactID: String) async { + guard let profile = activeConnection else { return } + if isLoadingToolArtifactDetail { return } + + isLoadingToolArtifactDetail = true + toolArtifactsError = nil + + do { + let detail = try await caelWorkspaceAPIService.loadToolArtifact(connection: profile, id: artifactID) + guard isActiveWorkspace(profile) else { return } + selectedToolArtifactDetail = detail + isLoadingToolArtifactDetail = false + } catch { + guard isActiveWorkspace(profile) else { return } + isLoadingToolArtifactDetail = false + toolArtifactsError = error.localizedDescription + setStatusMessage(L10n.string("Unable to load artifact detail")) + } + } + + private func loadSessionPage( + connection: ConnectionProfile, + offset: Int, + limit: Int, + query: String + ) async throws -> SessionListPage { + if query.isEmpty { + do { + return try await caelWorkspaceAPIService.loadWorkspaceSessions( + connection: connection, + offset: offset, + limit: limit + ) + } catch { + return try await sessionBrowserService.listSessions( + connection: connection, + offset: offset, + limit: limit, + query: query + ) + } + } + + return try await sessionBrowserService.listSessions( + connection: connection, + offset: offset, + limit: limit, + query: query + ) + } + + private func loadSessionMessages( + connection: ConnectionProfile, + sessionID: String + ) async throws -> [SessionMessage] { + do { + let response = try await caelWorkspaceAPIService.loadWorkspaceSessionHistory( + connection: connection, + sessionKey: sessionID + ) + if !response.messages.isEmpty { + return response.messages + } + } catch { + // Keep the legacy transcript reader as a compatibility fallback while + // Sessions moves onto the shared Workspace API contract. + } + + return try await sessionBrowserService.loadTranscript( + connection: connection, + sessionID: sessionID + ) + } + func loadSessions( reset: Bool = false, query: String? = nil, @@ -876,7 +1183,7 @@ final class AppState: ObservableObject { } do { - let page = try await sessionBrowserService.listSessions( + let page = try await loadSessionPage( connection: profile, offset: reset ? 0 : sessionOffset, limit: sessionPageSize, @@ -957,9 +1264,10 @@ final class AppState: ObservableObject { selectedSessionID = sessionID sessionsError = nil sessionConversationError = nil + workspaceSessionActiveRun = nil do { - let messages = try await sessionBrowserService.loadTranscript( + let messages = try await loadSessionMessages( connection: profile, sessionID: sessionID ) @@ -969,7 +1277,7 @@ final class AppState: ObservableObject { guard isActiveWorkspace(profile), selectedSessionID == sessionID else { return } clearSessionMessages() sessionsError = error.localizedDescription - setStatusMessage(L10n.string("Unable to load session transcript")) + setStatusMessage(L10n.string("Unable to load session history")) } } @@ -984,29 +1292,19 @@ final class AppState: ObservableObject { sessionCompactionNotice = nil sessionsError = nil sessionConversationError = nil + workspaceSessionActiveRun = nil selectedSessionDetailMode = .chat - startSessionTUI(sessionID: nil, replacesExisting: true) + stopSessionTUI() } func setSessionDetailMode(_ mode: SessionDetailMode) { - let previousMode = selectedSessionDetailMode selectedSessionDetailMode = mode - - switch mode { - case .transcript: - if previousMode == .chat { - Task { [weak self] in - await self?.refreshSessionsAfterChat() - } - } - case .chat: - startSessionTUIIfNeededForCurrentSelection() - } + stopSessionTUI() } func startSelectedSessionChat() { selectedSessionDetailMode = .chat - startSessionTUI(sessionID: selectedSessionID, replacesExisting: true) + stopSessionTUI() } func refreshSessionsAfterChat() async { @@ -1089,53 +1387,75 @@ final class AppState: ObservableObject { sessionScrollOffsets.removeValue(forKey: sessionID) } - func startNewSession(with prompt: String, autoApproveCommands: Bool) async -> Bool { + func startNewSession( + with prompt: String, + autoApproveCommands: Bool, + attachments: [WorkspaceChatAttachment] = [] + ) async -> Bool { guard let profile = activeConnection else { return false } guard !isSendingSessionMessage else { return false } let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPrompt.isEmpty else { return false } + guard !trimmedPrompt.isEmpty || !attachments.isEmpty else { return false } + let messagePrompt = trimmedPrompt.isEmpty + ? L10n.string(attachments.count == 1 ? "Please review the attached image." : "Please review the attached images.") + : trimmedPrompt - let existingVisibleSessionIDs = Set((sessions + pinnedSessionSummaries).map(\.id)) + // Normal desktop composer sends use the same Workspace API as the web + // app so both clients share the server-owned run ledger. isSendingSessionMessage = true pendingSessionTurn = PendingSessionTurn( sessionID: nil, - prompt: trimmedPrompt, + prompt: messagePrompt, autoApproveCommands: autoApproveCommands ) sessionConversationError = nil sessionsError = nil + appendPendingUserLiveMessage(prompt: messagePrompt) + do { - let turnResult = try await hermesChatService.sendMessage( - trimmedPrompt, - sessionID: nil, + let createdSession = try await caelWorkspaceAPIService.createWorkspaceChatSession( connection: profile, - autoApproveCommands: autoApproveCommands + label: String(messagePrompt.prefix(80)) + ) + let serverSessionID = (createdSession.sessionKey ?? createdSession.friendlyId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !serverSessionID.isEmpty else { + throw SSHTransportError.invalidResponse("Workspace API did not return a session key for the new chat session.") + } + let sendResponse = try await caelWorkspaceAPIService.sendWorkspaceSessionMessage( + connection: profile, + sessionKey: serverSessionID, + message: messagePrompt, + autoApproveCommands: autoApproveCommands, + attachments: attachments ) guard isActiveWorkspace(profile) else { return false } + let responseSessionID = sendResponse.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let createdSessionID = responseSessionID.isEmpty ? serverSessionID : responseSessionID isSendingSessionMessage = false pendingSessionTurn = nil + markAcceptedWorkspaceRun(sendResponse, sessionID: createdSessionID) sessionSearchQuery = "" + selectedSessionDetailMode = .chat + isNewSessionComposerActive = false + selectedSessionID = createdSessionID + upsertAcceptedWorkspaceSessionSummary( + sessionID: createdSessionID, + prompt: messagePrompt, + messageCount: max(sessionMessages.count + liveSessionMessageDisplays.count, 1) + ) + scheduleAcceptedWorkspaceSessionRefresh(sessionID: createdSessionID, connection: profile) await loadSessions( reset: true, query: "", - preferredSessionID: turnResult.sessionID, - allowsFallbackSelection: false + preferredSessionID: createdSessionID, + allowsFallbackSelection: false, + updatesSelection: false ) - - let createdSessionID = turnResult.sessionID ?? - likelyNewSessionID( - afterStartingWith: trimmedPrompt, - excluding: existingVisibleSessionIDs - ) ?? - sessions.first?.id - - if let createdSessionID { - await loadSessionDetail(sessionID: createdSessionID) - } return true } catch { guard isActiveWorkspace(profile) else { return false } @@ -1305,7 +1625,11 @@ final class AppState: ObservableObject { .lowercased() } - func sendMessageToSelectedSession(_ prompt: String, autoApproveCommands: Bool) async -> Bool { + func sendMessageToSelectedSession( + _ prompt: String, + autoApproveCommands: Bool, + attachments: [WorkspaceChatAttachment] = [] + ) async -> Bool { guard let profile = activeConnection, let selectedSessionID else { return false @@ -1313,7 +1637,10 @@ final class AppState: ObservableObject { guard !isSendingSessionMessage else { return false } let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPrompt.isEmpty else { return false } + guard !trimmedPrompt.isEmpty || !attachments.isEmpty else { return false } + let messagePrompt = trimmedPrompt.isEmpty + ? L10n.string(attachments.count == 1 ? "Please review the attached image." : "Please review the attached images.") + : trimmedPrompt if let compactionNotice = knownSessionCompactionNotice(for: selectedSessionID) ?? (sessionCompactionNotice?.sourceSessionID == selectedSessionID ? sessionCompactionNotice : nil) { @@ -1324,36 +1651,36 @@ final class AppState: ObservableObject { return false } + // Normal desktop composer sends use the same Workspace API as the web + // app so focus loss and active-run recovery behave identically. + isSendingSessionMessage = true pendingSessionTurn = PendingSessionTurn( sessionID: selectedSessionID, - prompt: trimmedPrompt, + prompt: messagePrompt, autoApproveCommands: autoApproveCommands ) sessionConversationError = nil sessionsError = nil - startSessionTranscriptPolling(sessionID: selectedSessionID, connection: profile) + appendPendingUserLiveMessage(prompt: messagePrompt) do { - _ = try await hermesChatService.sendMessage( - trimmedPrompt, - sessionID: selectedSessionID, + let sendResponse = try await caelWorkspaceAPIService.sendWorkspaceSessionMessage( connection: profile, - autoApproveCommands: autoApproveCommands + sessionKey: selectedSessionID, + message: messagePrompt, + autoApproveCommands: autoApproveCommands, + attachments: attachments ) guard isActiveWorkspace(profile) else { return false } - stopSessionTranscriptPolling() - if self.selectedSessionID == selectedSessionID { - await loadSessionDetail(sessionID: selectedSessionID) - } isSendingSessionMessage = false pendingSessionTurn = nil - await loadSessions(reset: true, query: sessionSearchQuery) + markAcceptedWorkspaceRun(sendResponse, sessionID: selectedSessionID) + scheduleAcceptedWorkspaceSessionRefresh(sessionID: selectedSessionID, connection: profile) return true } catch { guard isActiveWorkspace(profile) else { return false } - stopSessionTranscriptPolling() isSendingSessionMessage = false pendingSessionTurn = nil let message = error.localizedDescription @@ -1445,89 +1772,23 @@ final class AppState: ObservableObject { ) applyGatewaySessionResult(submitResult, preferredSessionID: resolvedSessionID) - let didComplete = await waitForActiveNativeTurnCompletion() - guard isActiveWorkspace(profile) else { return false } - - if didComplete { - stopSessionTranscriptPolling() - isSendingSessionMessage = false - pendingSessionTurn = nil - let sourceSessionID = sessionID ?? resolvedSessionID - var hydratedSessionIDs = Set() - - let provisionalSessionID = gatewaySessionID ?? sourceSessionID - if await hydrateSessionHistoryFromGateway( - sessionID: provisionalSessionID, - using: gatewayChatService, + isSendingSessionMessage = false + pendingSessionTurn = nil + hasAcceptedNativeTurnInFlight = true + Task { + await finishAcceptedNativeSessionTurn( profile: profile, - updatesSelection: provisionalSessionID == sourceSessionID - ) { - if provisionalSessionID == sourceSessionID { - hydratedSessionIDs.insert(provisionalSessionID) - } - } - - let refreshQuery = sessionID == nil ? "" : sessionSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - sessionSearchQuery = refreshQuery - await loadSessions( - reset: true, - query: refreshQuery, - allowsFallbackSelection: false, - updatesSelection: false - ) - - let confirmedCompactionNotice = await confirmSessionCompaction( - from: sourceSessionID, - preferredTargetSessionID: gatewaySessionID, - profile: profile - ) - let resolvedCompactedSessionID = confirmedCompactionNotice?.targetSessionID - if let confirmedCompactionNotice { - registerSessionCompaction(confirmedCompactionNotice) - } - - let canonicalSessionID = resolvedCompletedNativeTurnSessionID( - sourceSessionID: sourceSessionID, + sessionID: sessionID, + resolvedSessionID: resolvedSessionID, prompt: prompt, - excluding: existingVisibleSessionIDs, - compactedSessionID: resolvedCompactedSessionID + existingVisibleSessionIDs: existingVisibleSessionIDs, + gatewayChatService: gatewayChatService ) - - if let canonicalSessionID { - let didHydrateCanonicalSession: Bool - if hydratedSessionIDs.contains(canonicalSessionID) { - didHydrateCanonicalSession = true - } else { - didHydrateCanonicalSession = await hydrateSessionHistoryFromGateway( - sessionID: canonicalSessionID, - using: gatewayChatService, - profile: profile - ) - } - - if !didHydrateCanonicalSession && - ( - resolvedCompactedSessionID != nil || - selectedSessionID != canonicalSessionID || - sessionMessageDisplays.isEmpty - ) { - await loadSessionDetail(sessionID: canonicalSessionID) - } - } else if hydratedSessionIDs.isEmpty { - clearNativeTurnUIState() - } - return true } - - isSendingSessionMessage = false - pendingSessionTurn = nil - clearNativeTurnUIState() - let message = sessionConversationError ?? "Native chat did not complete successfully." - sessionConversationError = message - setStatusMessage(sessionStatusMessage(forConversationError: message, fallback: "Unable to send prompt to Hermes")) - return false + return true } catch { guard isActiveWorkspace(profile) else { return false } + hasAcceptedNativeTurnInFlight = false isSendingSessionMessage = false pendingSessionTurn = nil clearNativeTurnUIState() @@ -1539,6 +1800,97 @@ final class AppState: ObservableObject { } } + private func finishAcceptedNativeSessionTurn( + profile: ConnectionProfile, + sessionID: String?, + resolvedSessionID: String, + prompt: String, + existingVisibleSessionIDs: Set, + gatewayChatService: HermesGatewayChatService + ) async { + let didComplete = await waitForActiveNativeTurnCompletion() + hasAcceptedNativeTurnInFlight = false + guard isActiveWorkspace(profile) else { return } + + if didComplete { + stopSessionTranscriptPolling() + isSendingSessionMessage = false + pendingSessionTurn = nil + let sourceSessionID = sessionID ?? resolvedSessionID + var hydratedSessionIDs = Set() + + let provisionalSessionID = gatewaySessionID ?? sourceSessionID + if await hydrateSessionHistoryFromGateway( + sessionID: provisionalSessionID, + using: gatewayChatService, + profile: profile, + updatesSelection: provisionalSessionID == sourceSessionID + ) { + if provisionalSessionID == sourceSessionID { + hydratedSessionIDs.insert(provisionalSessionID) + } + } + + let refreshQuery = sessionID == nil ? "" : sessionSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + sessionSearchQuery = refreshQuery + await loadSessions( + reset: true, + query: refreshQuery, + allowsFallbackSelection: false, + updatesSelection: false + ) + + let confirmedCompactionNotice = await confirmSessionCompaction( + from: sourceSessionID, + preferredTargetSessionID: gatewaySessionID, + profile: profile + ) + let resolvedCompactedSessionID = confirmedCompactionNotice?.targetSessionID + if let confirmedCompactionNotice { + registerSessionCompaction(confirmedCompactionNotice) + } + + let canonicalSessionID = resolvedCompletedNativeTurnSessionID( + sourceSessionID: sourceSessionID, + prompt: prompt, + excluding: existingVisibleSessionIDs, + compactedSessionID: resolvedCompactedSessionID + ) + + if let canonicalSessionID { + let didHydrateCanonicalSession: Bool + if hydratedSessionIDs.contains(canonicalSessionID) { + didHydrateCanonicalSession = true + } else { + didHydrateCanonicalSession = await hydrateSessionHistoryFromGateway( + sessionID: canonicalSessionID, + using: gatewayChatService, + profile: profile + ) + } + + if !didHydrateCanonicalSession && + ( + resolvedCompactedSessionID != nil || + selectedSessionID != canonicalSessionID || + sessionMessageDisplays.isEmpty + ) { + await loadSessionDetail(sessionID: canonicalSessionID) + } + } else if hydratedSessionIDs.isEmpty { + clearNativeTurnUIState() + } + return + } + + isSendingSessionMessage = false + pendingSessionTurn = nil + clearNativeTurnUIState() + let message = sessionConversationError ?? "Native chat did not complete successfully." + sessionConversationError = message + setStatusMessage(sessionStatusMessage(forConversationError: message, fallback: "Unable to send prompt to Hermes")) + } + func deleteSession(_ session: SessionSummary) async { guard let profile = activeConnection else { return } if isDeletingSession { return } @@ -1611,6 +1963,100 @@ final class AppState: ObservableObject { } } + func loadCaelWorkspace(forceRefresh: Bool = false) async { + guard let profile = activeConnection else { return } + if isLoadingCaelWorkspace { return } + if !forceRefresh, + caelWorkspaceStatus != nil, + caelIntegrationStatus != nil, + (caelN8nGovernance != nil || caelN8nGovernanceError != nil), + caelCommandCenterSummary != nil, + caelCommandCenterSections != nil { + return + } + + if caelCommandCenterSummary == nil, caelCommandCenterSections == nil { + _ = applyCachedCommandCenterSnapshot( + for: profile, + reason: "Refreshing the live Workspace in the background." + ) + } + + isLoadingCaelWorkspace = true + caelWorkspaceError = nil + + do { + let loadedStatus = try await caelWorkspaceAPIService.loadStatus(connection: profile) + let loadedIntegrations = try await caelWorkspaceAPIService.loadIntegrations(connection: profile) + let loadedGovernance: CaelN8nGovernanceStatus? + let loadedGovernanceError: String? + do { + loadedGovernance = try await caelWorkspaceAPIService.loadN8nGovernance(connection: profile) + loadedGovernanceError = nil + } catch { + loadedGovernance = nil + loadedGovernanceError = error.localizedDescription + } + let loadedCommandCenter = try? await caelWorkspaceAPIService.loadCommandCenterSummary(connection: profile) + let loadedSections = await caelWorkspaceAPIService.loadCommandCenterSections(connection: profile) + guard isActiveCaelWorkspace(profile) else { return } + + caelWorkspaceStatus = loadedStatus + caelIntegrationStatus = loadedIntegrations + caelN8nGovernance = loadedGovernance + caelN8nGovernanceError = loadedGovernanceError + caelCommandCenterSummary = loadedCommandCenter?.data + caelCommandCenterSections = loadedSections + caelCommandCenterWarnings = loadedCommandCenter?.warnings ?? [] + caelCommandCenterCacheNotice = nil + try? caelCommandCenterSnapshotStore.save( + summaryEnvelope: loadedCommandCenter, + sections: loadedSections, + for: profile + ) + isLoadingCaelWorkspace = false + } catch { + guard isActiveCaelWorkspace(profile) else { return } + if caelCommandCenterSummary == nil, caelCommandCenterSections == nil { + _ = applyCachedCommandCenterSnapshot( + for: profile, + reason: "The live Workspace fetch failed: \(error.localizedDescription)" + ) + } + isLoadingCaelWorkspace = false + caelWorkspaceError = error.localizedDescription + setStatusMessage("Unable to load Cael workspace status") + } + } + + func loadCaelProviderUsage(forceRefresh: Bool = false) async { + guard let profile = activeConnection else { return } + if isLoadingCaelProviderUsage { return } + if !forceRefresh, caelProviderUsageLimits != nil { + return + } + + isLoadingCaelProviderUsage = true + caelProviderUsageError = nil + + do { + let limits = try await caelWorkspaceAPIService.loadProviderUsage( + connection: profile, + force: forceRefresh + ) + guard isActiveCaelWorkspace(profile) else { return } + + caelProviderUsageLimits = limits + isLoadingCaelProviderUsage = false + } catch { + guard isActiveCaelWorkspace(profile) else { return } + isLoadingCaelProviderUsage = false + caelProviderUsageLimits = nil + caelProviderUsageError = error.localizedDescription + setStatusMessage("Unable to load provider usage limits") + } + } + func loadSkills(reset: Bool = false) async { guard let profile = activeConnection else { return } if isLoadingSkills { return } @@ -1828,16 +2274,27 @@ final class AppState: ObservableObject { sessionsError = nil sessionConversationError = nil selectedSessionDetailMode = .chat - sessionTUITerminal = SessionTUITerminal( - sessionID: nil, - connection: profile.updated(), - sshTransport: sshTransport, + selectedSection = .sessions + + if await preferredChatTransport(for: profile) == .native { + setStatusMessage(L10n.string("Running %@ in native Chat…", workflow.name)) + _ = await startNativeSessionTurn( + prompt: invocation.initialInput, + sessionID: nil, + autoApproveCommands: false + ) + return + } + + sessionTUITerminal = SessionTUITerminal( + sessionID: nil, + connection: profile.updated(), + sshTransport: sshTransport, workflowLaunchDiagnostics: workflowLaunchDiagnostics, startupInput: invocation.initialInput, workflowLaunchDiagnosticsContext: workflowLaunchDiagnosticsContext ) - selectedSection = .sessions - setStatusMessage(L10n.string("Opening %@ in Chat…", workflow.name)) + setStatusMessage(L10n.string("Opening %@ in Chat TUI fallback…", workflow.name)) } func loadSkillDetail(summary: SkillSummary) async { @@ -1959,7 +2416,7 @@ final class AppState: ObservableObject { cronJobsError = nil do { - let jobs = try await cronBrowserService.listJobs(connection: profile) + let jobs = try await caelWorkspaceAPIService.loadWorkspaceCronJobs(connection: profile) guard isActiveWorkspace(profile) else { return } cronJobs = jobs isLoadingCronJobs = false @@ -1987,7 +2444,7 @@ final class AppState: ObservableObject { cronJobsError = nil do { - try await cronBrowserService.pauseJob(connection: profile, jobID: job.id) + try await caelWorkspaceAPIService.pauseWorkspaceCronJob(connection: profile, jobID: job.id) guard isActiveWorkspace(profile) else { return } await loadCronJobs() isOperatingOnCronJob = false @@ -2018,10 +2475,16 @@ final class AppState: ObservableObject { setStatusMessage(L10n.string("Creating cron job…")) do { - let jobID = try await cronBrowserService.createJob(connection: profile, draft: draft) + let result: CronJobMutationResult + if draft.noAgent { + let jobID = try await cronBrowserService.createJob(connection: profile, draft: draft) + result = CronJobMutationResult(jobID: jobID, job: nil) + } else { + result = try await caelWorkspaceAPIService.createWorkspaceCronJob(connection: profile, draft: draft) + } guard isActiveWorkspace(profile) else { return false } await loadCronJobs() - selectedCronJobID = jobID + selectedCronJobID = result.jobID ?? result.job?.id isSavingCronJobDraft = false setStatusMessage(L10n.string("%@ created", draft.normalizedName)) return true @@ -2050,7 +2513,11 @@ final class AppState: ObservableObject { setStatusMessage(L10n.string("Updating %@…", job.resolvedName)) do { - try await cronBrowserService.updateJob(connection: profile, jobID: job.id, draft: draft) + if draft.noAgent || job.noAgent { + try await cronBrowserService.updateJob(connection: profile, jobID: job.id, draft: draft) + } else { + _ = try await caelWorkspaceAPIService.updateWorkspaceCronJob(connection: profile, jobID: job.id, draft: draft) + } guard isActiveWorkspace(profile) else { return false } await loadCronJobs() selectedCronJobID = job.id @@ -2075,7 +2542,7 @@ final class AppState: ObservableObject { cronJobsError = nil do { - try await cronBrowserService.resumeJob(connection: profile, jobID: job.id) + try await caelWorkspaceAPIService.resumeWorkspaceCronJob(connection: profile, jobID: job.id) guard isActiveWorkspace(profile) else { return } await loadCronJobs() isOperatingOnCronJob = false @@ -2099,7 +2566,7 @@ final class AppState: ObservableObject { cronJobsError = nil do { - try await cronBrowserService.removeJob(connection: profile, jobID: job.id) + try await caelWorkspaceAPIService.deleteWorkspaceCronJob(connection: profile, jobID: job.id) guard isActiveWorkspace(profile) else { return } await loadCronJobs() isOperatingOnCronJob = false @@ -2124,7 +2591,7 @@ final class AppState: ObservableObject { setStatusMessage(L10n.string("Triggering %@…", job.resolvedName)) do { - try await cronBrowserService.runJobNow(connection: profile, jobID: job.id) + try await caelWorkspaceAPIService.triggerWorkspaceCronJob(connection: profile, jobID: job.id) guard isActiveWorkspace(profile) else { return } await loadCronJobs() isOperatingOnCronJob = false @@ -2150,26 +2617,27 @@ final class AppState: ObservableObject { let response = try await kanbanBrowserService.loadBoards(connection: profile) guard isActiveWorkspace(profile) else { return } - kanbanBoards = response.boards.isEmpty + let remoteBoards = response.boards.isEmpty ? [KanbanProject(slug: KanbanProject.defaultSlug)] : response.boards + kanbanBoards = [KanbanProject.workspaceTasks] + remoteBoards remoteCurrentKanbanBoardSlug = response.current supportsKanbanBoardManagement = response.supportsBoardManagement if !kanbanBoards.contains(where: { $0.slug == selectedKanbanBoardSlug }) { - if let current = response.current, - kanbanBoards.contains(where: { $0.slug == current }) { - selectedKanbanBoardSlug = current - } else { - selectedKanbanBoardSlug = kanbanBoards.first?.slug ?? KanbanProject.defaultSlug - } + selectedKanbanBoardSlug = KanbanProject.workspaceTasksSlug + } else if selectedKanbanBoardSlug == KanbanProject.defaultSlug { + selectedKanbanBoardSlug = KanbanProject.workspaceTasksSlug } isLoadingKanbanBoards = false } catch { guard isActiveWorkspace(profile) else { return } isLoadingKanbanBoards = false - kanbanBoards = kanbanBoards.isEmpty ? [KanbanProject(slug: KanbanProject.defaultSlug)] : kanbanBoards + kanbanBoards = kanbanBoards.isEmpty ? [KanbanProject.workspaceTasks, KanbanProject(slug: KanbanProject.defaultSlug)] : kanbanBoards + if !kanbanBoards.contains(where: { $0.slug == selectedKanbanBoardSlug }) { + selectedKanbanBoardSlug = KanbanProject.workspaceTasksSlug + } remoteCurrentKanbanBoardSlug = nil supportsKanbanBoardManagement = false kanbanError = error.localizedDescription @@ -2208,11 +2676,20 @@ final class AppState: ObservableObject { kanbanError = nil do { - let board = try await kanbanBrowserService.loadBoard( - connection: profile, - boardSlug: boardSlug, - includeArchived: includeArchivedKanbanTasks - ) + let board: KanbanBoard + if boardSlug == KanbanProject.workspaceTasksSlug { + let tasks = try await caelWorkspaceAPIService.loadWorkspaceTasks( + connection: profile, + includeDone: includeArchivedKanbanTasks + ) + board = .workspaceTasks(tasks, includeDone: includeArchivedKanbanTasks) + } else { + board = try await kanbanBrowserService.loadBoard( + connection: profile, + boardSlug: boardSlug, + includeArchived: includeArchivedKanbanTasks + ) + } guard isActiveWorkspace(profile), selectedKanbanBoardSlug == boardSlug else { return } kanbanBoard = board isLoadingKanbanBoard = false @@ -2247,6 +2724,12 @@ final class AppState: ObservableObject { isLoadingKanbanTaskDetail = true kanbanError = nil + if boardSlug == KanbanProject.workspaceTasksSlug { + selectedKanbanTaskDetail = nil + isLoadingKanbanTaskDetail = false + return + } + do { let detail = try await kanbanBrowserService.loadTaskDetail( connection: profile, @@ -2350,11 +2833,23 @@ final class AppState: ObservableObject { do { let boardSlug = selectedKanbanBoardSlug - let taskID = try await kanbanBrowserService.createTask(connection: profile, boardSlug: boardSlug, draft: draft) + let taskID: String + if boardSlug == KanbanProject.workspaceTasksSlug { + taskID = try await caelWorkspaceAPIService.createWorkspaceTask( + connection: profile, + draft: draft + ).id + } else { + taskID = try await kanbanBrowserService.createTask(connection: profile, boardSlug: boardSlug, draft: draft) + } guard isActiveWorkspace(profile), selectedKanbanBoardSlug == boardSlug else { return false } await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) selectedKanbanTaskID = taskID - await loadKanbanTaskDetail(taskID: taskID) + if boardSlug == KanbanProject.workspaceTasksSlug { + selectedKanbanTaskDetail = nil + } else { + await loadKanbanTaskDetail(taskID: taskID) + } isSavingKanbanTaskDraft = false setStatusMessage(L10n.string("Kanban task created")) return true @@ -2367,6 +2862,191 @@ final class AppState: ObservableObject { } } + func moveWorkspaceKanbanTask(taskID: String, to status: KanbanTaskStatus) async { + guard let profile = activeConnection else { return } + guard isWorkspaceKanbanBoardSelected, !isOperatingOnKanbanTask else { return } + + isOperatingOnKanbanTask = true + operatingKanbanTaskID = taskID + kanbanError = nil + + do { + try await caelWorkspaceAPIService.moveWorkspaceTask( + connection: profile, + taskID: taskID, + column: WorkspaceTaskColumn.fromKanbanStatus(status) + ) + guard isActiveWorkspace(profile), isWorkspaceKanbanBoardSelected else { return } + await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) + selectedKanbanTaskID = taskID + selectedKanbanTaskDetail = nil + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + setStatusMessage(L10n.string("Workspace task moved")) + } catch { + guard isActiveWorkspace(profile) else { return } + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + kanbanError = error.localizedDescription + setStatusMessage(L10n.string("Unable to move Workspace task")) + } + } + + func deleteWorkspaceKanbanTask(taskID: String) async { + guard let profile = activeConnection else { return } + guard isWorkspaceKanbanBoardSelected, !isOperatingOnKanbanTask else { return } + + isOperatingOnKanbanTask = true + operatingKanbanTaskID = taskID + kanbanError = nil + + do { + try await caelWorkspaceAPIService.deleteWorkspaceTask(connection: profile, taskID: taskID) + guard isActiveWorkspace(profile), isWorkspaceKanbanBoardSelected else { return } + await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) + if selectedKanbanTaskID == taskID { + selectedKanbanTaskID = kanbanBoard?.tasks.first?.id + } + selectedKanbanTaskDetail = nil + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + setStatusMessage(L10n.string("Workspace task deleted")) + } catch { + guard isActiveWorkspace(profile) else { return } + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + kanbanError = error.localizedDescription + setStatusMessage(L10n.string("Unable to delete Workspace task")) + } + } + + func updateWorkspaceKanbanTask(taskID: String, draft: KanbanTaskDraft) async -> Bool { + guard let profile = activeConnection else { return false } + guard isWorkspaceKanbanBoardSelected, !isOperatingOnKanbanTask else { return false } + + if let validationError = draft.validationError { + let localizedError = L10n.string(validationError) + kanbanError = localizedError + setStatusMessage(localizedError) + return false + } + + isOperatingOnKanbanTask = true + operatingKanbanTaskID = taskID + kanbanError = nil + + do { + try await caelWorkspaceAPIService.updateWorkspaceTask( + connection: profile, + taskID: taskID, + title: draft.normalizedTitle, + description: draft.normalizedBody ?? "", + priority: WorkspaceTaskPriority.fromKanbanPriority(draft.priority), + assignee: draft.normalizedAssignee, + tags: draft.skills + ) + guard isActiveWorkspace(profile), isWorkspaceKanbanBoardSelected else { return false } + await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) + selectedKanbanTaskID = taskID + selectedKanbanTaskDetail = nil + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + setStatusMessage(L10n.string("Workspace task updated")) + return true + } catch { + guard isActiveWorkspace(profile) else { return false } + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + kanbanError = error.localizedDescription + setStatusMessage(L10n.string("Unable to update Workspace task")) + return false + } + } + + + func launchWorkspaceKanbanTaskSession(taskID: String) async { + guard let profile = activeConnection else { return } + guard isWorkspaceKanbanBoardSelected, !isOperatingOnKanbanTask else { return } + + isOperatingOnKanbanTask = true + operatingKanbanTaskID = taskID + kanbanError = nil + setStatusMessage(L10n.string("Launching Workspace task session...")) + + do { + let launch = try await caelWorkspaceAPIService.launchWorkspaceTaskSession(connection: profile, taskID: taskID) + let sessionID = launch.sessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !sessionID.isEmpty else { + throw SSHTransportError.invalidResponse("Workspace API did not return a task session id.") + } + _ = try await caelWorkspaceAPIService.linkWorkspaceTaskSession(connection: profile, taskID: taskID, sessionID: sessionID) + guard isActiveWorkspace(profile), isWorkspaceKanbanBoardSelected else { return } + await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) + selectedKanbanTaskID = taskID + selectedKanbanTaskDetail = nil + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + await openWorkspaceTaskSession(sessionID: sessionID) + setStatusMessage(L10n.string("Workspace task session launched")) + } catch { + guard isActiveWorkspace(profile) else { return } + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + kanbanError = error.localizedDescription + setStatusMessage(L10n.string("Unable to launch Workspace task session")) + } + } + + func linkWorkspaceKanbanTaskSession(taskID: String, sessionID: String?) async { + guard let profile = activeConnection else { return } + guard isWorkspaceKanbanBoardSelected, !isOperatingOnKanbanTask else { return } + let trimmedSessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedSessionID = trimmedSessionID.isEmpty ? nil : trimmedSessionID + + isOperatingOnKanbanTask = true + operatingKanbanTaskID = taskID + kanbanError = nil + + do { + _ = try await caelWorkspaceAPIService.linkWorkspaceTaskSession( + connection: profile, + taskID: taskID, + sessionID: normalizedSessionID + ) + guard isActiveWorkspace(profile), isWorkspaceKanbanBoardSelected else { return } + await loadKanbanBoard(includeArchived: includeArchivedKanbanTasks) + selectedKanbanTaskID = taskID + selectedKanbanTaskDetail = nil + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + setStatusMessage(normalizedSessionID == nil ? L10n.string("Workspace task session link cleared") : L10n.string("Workspace task session linked")) + } catch { + guard isActiveWorkspace(profile) else { return } + isOperatingOnKanbanTask = false + operatingKanbanTaskID = nil + kanbanError = error.localizedDescription + setStatusMessage(L10n.string("Unable to link Workspace task session")) + } + } + + func openWorkspaceTaskSession(sessionID: String) async { + let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionID.isEmpty else { return } + + stopSessionTUI() + isNewSessionComposerActive = false + selectedSessionDetailMode = .chat + selectedSection = .sessions + await loadSessions( + reset: true, + query: "", + preferredSessionID: normalizedSessionID, + allowsFallbackSelection: false, + updatesSelection: false + ) + await loadSessionDetail(sessionID: normalizedSessionID) + } + func addKanbanComment(taskID: String, body: String) async -> Bool { guard let profile = activeConnection else { return false } guard !isOperatingOnKanbanTask else { return false } @@ -2738,9 +3418,14 @@ final class AppState: ObservableObject { } private func handleSectionEntry(_ section: AppSection) { + if section.isCommandCenterMirrorSection { + Task { await loadCaelWorkspace() } + return + } + switch section { case .overview: - Task { await refreshOverview() } + Task { await loadCaelWorkspace() } case .files: Task { await ensureInitialFileLoads() } case .sessions: @@ -2756,12 +3441,15 @@ final class AppState: ObservableObject { case .kanban: Task { await loadKanbanBoard() } case .usage: - Task { await loadUsage(forceRefresh: true) } + Task { + await loadUsage(forceRefresh: true) + await loadCaelProviderUsage(forceRefresh: true) + } case .skills: Task { await loadSkills(reset: true) } case .terminal: ensureTerminalSession() - case .connections: + case .connections, .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp, .profiles: break } } @@ -2834,6 +3522,10 @@ final class AppState: ObservableObject { activeConnection?.workspaceScopeFingerprint == profile.workspaceScopeFingerprint } + private func isActiveCaelWorkspace(_ profile: ConnectionProfile) -> Bool { + activeConnection?.commandCenterClientFingerprint == profile.commandCenterClientFingerprint + } + private func setDocument(_ document: FileEditorDocument) { workspaceFileDocuments[document.fileID] = document } @@ -2847,6 +3539,11 @@ final class AppState: ObservableObject { } private func reloadSectionAfterScopeChange(_ section: AppSection) async { + if section.isCommandCenterMirrorSection { + await loadCaelWorkspace() + return + } + switch section { case .connections, .overview: break @@ -2868,10 +3565,14 @@ final class AppState: ObservableObject { await loadSkills(reset: true) case .terminal: ensureTerminalSession() + case .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp, .profiles: + break } } private func clearSessionMessages() { + acceptedWorkspaceChatEventsTask?.cancel() + acceptedWorkspaceChatEventsTask = nil guard !sessionMessages.isEmpty || !sessionMessageDisplays.isEmpty || !liveSessionMessageDisplays.isEmpty || @@ -3086,6 +3787,7 @@ final class AppState: ObservableObject { gatewaySessionID = nil activeGatewayAssistantMessageID = nil clearNativeTurnUIState() + hasAcceptedNativeTurnInFlight = false completeActiveNativeTurn(success: false) activeNativeTurnResult = nil activeNativeTurnCompletion = nil @@ -3135,10 +3837,258 @@ final class AppState: ObservableObject { } private func prepareActiveNativeTurnWait() { + hasAcceptedNativeTurnInFlight = false activeNativeTurnResult = nil activeNativeTurnCompletion = nil } + private func scheduleAcceptedWorkspaceSessionRefresh(sessionID: String, connection: ConnectionProfile) { + let workspaceScopeFingerprint = connection.workspaceScopeFingerprint + acceptedWorkspaceSessionMonitorTask?.cancel() + acceptedWorkspaceChatEventsTask?.cancel() + acceptedWorkspaceChatEventsTask = Task { [weak self] in + var sawTerminalEvent = false + while !Task.isCancelled, !sawTerminalEvent { + sawTerminalEvent = await self?.tailAcceptedWorkspaceChatEvents( + sessionID: sessionID, + workspaceScopeFingerprint: workspaceScopeFingerprint, + timeoutSeconds: 8 + ) ?? false + if sawTerminalEvent { return } + try? await Task.sleep(for: .seconds(1)) + } + } + acceptedWorkspaceSessionMonitorTask = Task { [weak self] in + let startedAt = Date() + var sawActiveRun = false + var emptyRunChecks = 0 + + for delay in [1.2, 3, 5, 8, 13] as [Double] { + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + + let run = await self?.refreshAcceptedWorkspaceSession( + sessionID: sessionID, + workspaceScopeFingerprint: workspaceScopeFingerprint + ) + if let run { + sawActiveRun = true + emptyRunChecks = 0 + if Self.isTerminalWorkspaceRunStatus(run.status) { return } + } else { + emptyRunChecks += 1 + if sawActiveRun { return } + } + } + + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 10 * 60 { + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled else { return } + + let run = await self?.refreshAcceptedWorkspaceSession( + sessionID: sessionID, + workspaceScopeFingerprint: workspaceScopeFingerprint + ) + if let run { + sawActiveRun = true + emptyRunChecks = 0 + if Self.isTerminalWorkspaceRunStatus(run.status) { return } + } else { + emptyRunChecks += 1 + if sawActiveRun || emptyRunChecks >= 2 { return } + } + } + } + } + + @discardableResult + private func refreshAcceptedWorkspaceSession(sessionID: String, workspaceScopeFingerprint: String) async -> WorkspaceSessionActiveRun? { + guard let profile = activeConnection, + profile.workspaceScopeFingerprint == workspaceScopeFingerprint else { return nil } + var activeRun: WorkspaceSessionActiveRun? + if selectedSessionID == sessionID { + activeRun = await refreshWorkspaceSessionActiveRun(sessionID: sessionID, connection: profile) + let loadedWorkspaceHistory = await hydrateWorkspaceSessionHistory(sessionID: sessionID, connection: profile) + if !loadedWorkspaceHistory { + await loadSessionDetail(sessionID: sessionID) + } + } + await loadSessions( + reset: true, + query: sessionSearchQuery, + preferredSessionID: sessionID, + allowsFallbackSelection: false, + updatesSelection: false + ) + return activeRun + } + + nonisolated private static func isTerminalWorkspaceRunStatus(_ status: String) -> Bool { + switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "complete", "completed", "succeeded", "success", "done", "failed", "failure", "error", "cancelled", "canceled": + return true + default: + return false + } + } + + private func upsertAcceptedWorkspaceSessionSummary(sessionID: String, prompt: String, messageCount: Int) { + let now = SessionTimestamp.unixSeconds(Date().timeIntervalSince1970) + let title = String(prompt.prefix(80)) + let summary = SessionSummary( + id: sessionID, + title: title, + model: nil, + startedAt: now, + lastActive: now, + messageCount: messageCount, + preview: prompt + ) + if let index = sessions.firstIndex(where: { $0.id == sessionID }) { + sessions[index] = summary + } else { + sessions.insert(summary, at: 0) + totalSessionsCount = max(totalSessionsCount, sessions.count) + } + } + + private func markAcceptedWorkspaceRun(_ response: WorkspaceSessionSendResponse, sessionID: String) { + guard let runID = response.runId?.trimmingCharacters(in: .whitespacesAndNewlines), + !runID.isEmpty else { + return + } + let now = Date().timeIntervalSince1970 * 1000 + workspaceSessionActiveRun = WorkspaceSessionActiveRun( + runId: runID, + sessionKey: sessionID, + friendlyId: sessionID, + status: "accepted", + createdAt: now, + updatedAt: now, + lastEventAt: now, + assistantText: "", + thinkingText: "", + toolCalls: [], + lifecycleEvents: [], + errorMessage: nil + ) + } + + @discardableResult + private func refreshWorkspaceSessionActiveRun(sessionID: String, connection: ConnectionProfile) async -> WorkspaceSessionActiveRun? { + do { + let response = try await caelWorkspaceAPIService.loadWorkspaceSessionActiveRun( + connection: connection, + sessionKey: sessionID + ) + guard isActiveWorkspace(connection), selectedSessionID == sessionID else { return nil } + workspaceSessionActiveRun = response.run + return response.run + } catch { + return nil + } + } + + @discardableResult + private func tailAcceptedWorkspaceChatEvents( + sessionID: String, + workspaceScopeFingerprint: String, + timeoutSeconds: Int + ) async -> Bool { + guard let profile = activeConnection, + profile.workspaceScopeFingerprint == workspaceScopeFingerprint, + selectedSessionID == sessionID else { return true } + + do { + let response = try await caelWorkspaceAPIService.tailWorkspaceChatEvents( + connection: profile, + sessionKey: sessionID, + timeoutSeconds: timeoutSeconds + ) + guard isActiveWorkspace(profile), selectedSessionID == sessionID else { return true } + var terminalEvent = false + for event in response.events { + terminalEvent = applyWorkspaceChatEvent(event, sessionID: sessionID) || terminalEvent + } + return terminalEvent + } catch { + return false + } + } + + @discardableResult + private func applyWorkspaceChatEvent(_ event: WorkspaceChatEvent, sessionID: String) -> Bool { + if let eventSessionID = gatewayValue(in: event.data, keys: ["sessionKey"]), + eventSessionID != sessionID { + return false + } + + switch event.event { + case "connected", "heartbeat", "started", "user_message": + return false + case "message": + startLiveAssistantMessage(with: workspaceAssistantMessagePayload(from: event.data)) + return false + case "chunk": + if event.data["fullReplace"]?.boolValue == true { + replaceLiveAssistantText(from: event.data) + } else { + appendLiveAssistantDelta(from: event.data) + } + return false + case "thinking": + if let thinking = gatewayValue(in: event.data, keys: ["text", "delta", "content"]) { + appendLiveSystemMessage(thinking) + } + return false + case "tool", "artifact": + let state = gatewayValue(in: event.data, keys: ["phase", "state", "status"])?.lowercased() ?? "" + updateToolActivityCard(for: event.data, defaultRunning: state != "complete" && state != "completed") + return false + case "done": + completeLiveAssistantMessage(from: event.data) + return true + case "error": + let message = gatewayValue(in: event.data, keys: ["message", "error"]) ?? "Workspace chat event stream reported an error." + appendLiveSystemMessage(message) + sessionConversationError = message + return true + default: + return false + } + } + + private func workspaceAssistantMessagePayload(from payload: [String: JSONValue]) -> [String: JSONValue] { + guard let message = payload["message"]?.objectValue else { return payload } + return [ + "id": message["id"] ?? .string(UUID().uuidString), + "text": .string("") + ] + } + + private func hydrateWorkspaceSessionHistory(sessionID: String, connection: ConnectionProfile) async -> Bool { + do { + let response = try await caelWorkspaceAPIService.loadWorkspaceSessionHistory( + connection: connection, + sessionKey: sessionID + ) + guard isActiveWorkspace(connection), selectedSessionID == sessionID else { return false } + guard !response.messages.isEmpty else { return false } + await setSessionMessages(response.messages, for: connection, sessionID: sessionID) + if let last = response.messages.last { + upsertAcceptedWorkspaceSessionSummary( + sessionID: sessionID, + prompt: last.content ?? sessionID, + messageCount: response.messages.count + ) + } + return true + } catch { + return false + } + } + private func waitForActiveNativeTurnCompletion() async -> Bool { if let activeNativeTurnResult { return activeNativeTurnResult @@ -3199,13 +4149,13 @@ final class AppState: ObservableObject { upsertPromptCard(kind: .secret, payload: event.payload, fallbackSessionID: event.sessionID) case "error": let message = gatewayValue(in: event.payload, keys: ["message", "error"]) ?? "Unknown gateway error" - if isSendingSessionMessage { + if isSendingSessionMessage || hasAcceptedNativeTurnInFlight { appendLiveSystemMessage(message) } sessionConversationError = message completeActiveNativeTurn(success: false) case "gateway.closed": - if isSendingSessionMessage { + if isSendingSessionMessage || hasAcceptedNativeTurnInFlight { let message = gatewayValue(in: event.payload, keys: ["error"]) ?? "Native chat disconnected." appendLiveSystemMessage(message) sessionConversationError = message @@ -3292,6 +4242,32 @@ final class AppState: ObservableObject { ) } + private func replaceLiveAssistantText(from payload: [String: JSONValue]) { + let text = gatewayValue(in: payload, keys: ["text", "content"]) ?? "" + guard !text.isEmpty else { return } + + if activeGatewayAssistantMessageID == nil { + startLiveAssistantMessage(with: payload) + return + } + + guard let messageID = activeGatewayAssistantMessageID, + let index = liveSessionMessageDisplays.lastIndex(where: { $0.id == messageID }) else { + return + } + + let existing = liveSessionMessageDisplays[index] + liveSessionMessageDisplays[index] = SessionMessageDisplay( + id: existing.id, + role: existing.role, + content: text, + timestampText: existing.timestampText, + metadataItems: existing.metadataItems, + toolSummary: existing.toolSummary, + isStreaming: true + ) + } + private func completeLiveAssistantMessage(from payload: [String: JSONValue]) { if activeGatewayAssistantMessageID == nil { startLiveAssistantMessage(with: payload) @@ -3614,8 +4590,9 @@ final class AppState: ObservableObject { isSendingSessionMessage = false sessionConversationError = nil pendingSessionTurn = nil + hasAcceptedNativeTurnInFlight = false sessionCompactionNotice = nil - selectedSessionDetailMode = .transcript + selectedSessionDetailMode = .chat stopSessionTUI() clearNativeTurnUIState() nativeChatBootstrapStatus = nil @@ -3674,6 +4651,39 @@ final class AppState: ObservableObject { await loadSessions(reset: true) } + + @discardableResult + private func applyCachedCommandCenterSnapshot(for profile: ConnectionProfile, reason: String) -> Bool { + guard let cached = caelCommandCenterSnapshotStore.load(for: profile) else { return false } + caelCommandCenterSummary = cached.summaryEnvelope?.data + caelCommandCenterSections = cached.sections + let cachedAt = cached.cachedAt.formatted(date: .abbreviated, time: .shortened) + let notice = "Showing last-known command center snapshot from \(cachedAt). \(reason)" + var warnings = cached.summaryEnvelope?.warnings ?? [] + warnings.insert(notice, at: 0) + caelCommandCenterWarnings = warnings + caelCommandCenterCacheNotice = notice + return true + } + + private func resetCaelWorkspaceState() { + caelWorkspaceStatus = nil + caelIntegrationStatus = nil + caelProviderUsageLimits = nil + caelN8nGovernance = nil + caelN8nGovernanceError = nil + caelCommandCenterSummary = nil + caelCommandCenterSections = nil + caelCommandCenterWarnings = [] + caelCommandCenterCacheNotice = nil + caelWorkspaceError = nil + caelProviderUsageError = nil + isLoadingCaelWorkspace = false + isRefreshingCaelWorkspace = false + isLoadingCaelProviderUsage = false + isRefreshingCaelProviderUsage = false + } + private func resetWorkspaceStateForConnectionChange(closeTerminalTabs: Bool = true) { isBusy = false connectionTestRequestID = nil @@ -3689,8 +4699,9 @@ final class AppState: ObservableObject { isSendingSessionMessage = false sessionConversationError = nil pendingSessionTurn = nil + hasAcceptedNativeTurnInFlight = false sessionCompactionNotice = nil - selectedSessionDetailMode = .transcript + selectedSessionDetailMode = .chat stopSessionTUI() clearNativeTurnUIState() nativeChatBootstrapStatus = nil @@ -3714,6 +4725,7 @@ final class AppState: ObservableObject { usageError = nil isLoadingUsage = false isRefreshingUsage = false + resetCaelWorkspaceState() skills = [] selectedSkillID = nil selectedSkillDetail = nil @@ -3759,7 +4771,12 @@ final class AppState: ObservableObject { workspaceFileDocuments = [:] workspaceFileBrowserListing = nil workspaceFileBrowserError = nil + workspaceFileBrowserNotice = nil isLoadingWorkspaceFileBrowser = false + workspacePreviewFile = nil + workspacePreviewError = nil + isLoadingWorkspacePreview = false + isUploadingWorkspaceFile = false selectedWorkspaceFileID = RemoteTrackedFile.memory.workspaceFileID } diff --git a/Sources/HermesDesktop/App/HermesDesktopApp.swift b/Sources/HermesDesktop/App/HermesDesktopApp.swift index c10e3ac..fe53959 100644 --- a/Sources/HermesDesktop/App/HermesDesktopApp.swift +++ b/Sources/HermesDesktop/App/HermesDesktopApp.swift @@ -7,10 +7,13 @@ struct HermesDesktopApp: App { @StateObject private var appState = AppState() var body: some Scene { - WindowGroup("Hermes Desktop") { + WindowGroup("Cael Desktop") { RootView() .environmentObject(appState) .frame(minWidth: 940, minHeight: 520) + .tint(HermesTheme.accent) + .preferredColorScheme(.dark) + .background(HermesTheme.background) .background(HermesWindowTitleBarConfigurator()) } .defaultSize(width: 1360, height: 860) diff --git a/Sources/HermesDesktop/App/HermesDesktopCommands.swift b/Sources/HermesDesktop/App/HermesDesktopCommands.swift index 88b672e..e96bbd9 100644 --- a/Sources/HermesDesktop/App/HermesDesktopCommands.swift +++ b/Sources/HermesDesktop/App/HermesDesktopCommands.swift @@ -66,11 +66,18 @@ struct HermesDesktopCommands: Commands { CommandMenu(L10n.string("Navigate")) { ForEach(AppSection.allCases) { section in - Button(L10n.string("Show %@", section.title)) { - appState.requestSectionSelection(section) + if let shortcut = section.navigationShortcutKey { + Button(L10n.string("Show %@", section.title)) { + appState.requestSectionSelection(section) + } + .keyboardShortcut(shortcut, modifiers: [.command]) + .disabled(!appState.isSectionAvailable(section)) + } else { + Button(L10n.string("Show %@", section.title)) { + appState.requestSectionSelection(section) + } + .disabled(!appState.isSectionAvailable(section)) } - .keyboardShortcut(section.navigationShortcutKey, modifiers: [.command]) - .disabled(!appState.isSectionAvailable(section)) } } } diff --git a/Sources/HermesDesktop/Models/AppSection.swift b/Sources/HermesDesktop/Models/AppSection.swift index 72ef2b4..ee68899 100644 --- a/Sources/HermesDesktop/Models/AppSection.swift +++ b/Sources/HermesDesktop/Models/AppSection.swift @@ -6,11 +6,21 @@ enum AppSection: String, CaseIterable, Identifiable { case overview case files case sessions + case mail + case contacts + case calendar case workflows case cronjobs case kanban + case missionControl + case operations + case swarm case usage + case memory case skills + case integrations + case mcp + case profiles case terminal var id: String { rawValue } @@ -24,21 +34,41 @@ enum AppSection: String, CaseIterable, Identifiable { case .connections: "Connections" case .overview: - "Overview" + "Homebase" case .files: - "Files" + "Artifacts" case .sessions: - "Sessions" + "Cael Sessions" + case .mail: + "Mail" + case .contacts: + "Contacts" + case .calendar: + "Calendar" case .workflows: "Workflows" case .cronjobs: - "Cron Jobs" + "Watchdogs" case .kanban: - "Kanban" + "Tasks" + case .missionControl: + "Mission Control" + case .operations: + "Ops" + case .swarm: + "Swarm" case .usage: "Usage" + case .memory: + "Memory" case .skills: "Skills" + case .integrations: + "Integrations" + case .mcp: + "MCP" + case .profiles: + "Profiles" case .terminal: "Terminal" } @@ -54,22 +84,42 @@ enum AppSection: String, CaseIterable, Identifiable { "doc.text" case .sessions: "bubble.left.and.bubble.right" + case .mail: + "envelope" + case .contacts: + "person.2" + case .calendar: + "calendar" case .workflows: "bookmark.square" case .cronjobs: "calendar.badge.clock" case .kanban: "rectangle.3.group" + case .missionControl: + "paperplane" + case .operations: + "person.2.wave.2" + case .swarm: + "person.3.sequence" case .usage: "chart.bar.xaxis" + case .memory: + "brain.head.profile" case .skills: "book.closed" + case .integrations: + "link" + case .mcp: + "point.3.connected.trianglepath.dotted" + case .profiles: + "person.crop.circle.badge.checkmark" case .terminal: "terminal" } } - var navigationShortcutKey: KeyEquivalent { + var navigationShortcutKey: KeyEquivalent? { switch self { case .connections: return "1" @@ -91,6 +141,17 @@ enum AppSection: String, CaseIterable, Identifiable { return "9" case .terminal: return "0" + case .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp, .profiles: + return nil + } + } + + var isCommandCenterMirrorSection: Bool { + switch self { + case .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp: + return true + case .connections, .overview, .files, .sessions, .workflows, .cronjobs, .kanban, .usage, .skills, .profiles, .terminal: + return false } } } diff --git a/Sources/HermesDesktop/Models/CaelWorkspaceModels.swift b/Sources/HermesDesktop/Models/CaelWorkspaceModels.swift new file mode 100644 index 0000000..ffe55d1 --- /dev/null +++ b/Sources/HermesDesktop/Models/CaelWorkspaceModels.swift @@ -0,0 +1,1762 @@ +import Foundation + +struct WorkspaceChatAttachment: Codable, Identifiable, Equatable { + let id: String + let name: String + let fileName: String + let type: String + let contentType: String + let mimeType: String + let mediaType: String + let content: String + let base64: String + let data: String + let dataUrl: String + let size: Int + + static func imagePNG(data: Data, name: String? = nil) -> WorkspaceChatAttachment { + let attachmentName = name ?? "pasted-image-\(UUID().uuidString.prefix(8)).png" + let encoded = data.base64EncodedString() + return WorkspaceChatAttachment( + id: UUID().uuidString, + name: attachmentName, + fileName: attachmentName, + type: "image", + contentType: "image/png", + mimeType: "image/png", + mediaType: "image/png", + content: encoded, + base64: encoded, + data: encoded, + dataUrl: "data:image/png;base64,\(encoded)", + size: data.count + ) + } +} + +struct CaelWorkspaceStatus: Codable { + let ok: Bool + let generatedAt: String + let host: String + let posture: CaelWorkspacePosture + let services: [CaelWorkspaceServiceCheck] + let links: [CaelWorkspaceLink] + let contextSurfaces: [CaelWorkspaceContextSurface]? + let contract: CaelCommandCenterContract +} + +struct CaelWorkspacePosture: Codable { + let bind: String + let remoteAccess: String + let auth: String + let publicInternet: String +} + +struct CaelWorkspaceServiceCheck: Codable, Identifiable { + let id: String + let label: String + let kind: String + let target: String + let ok: Bool + let detail: String + let latencyMs: Double? + let lane: String? + let owner: String? + let description: String? +} + +struct CaelWorkspaceLink: Codable, Identifiable { + var id: String { href } + let label: String + let href: String + let description: String +} + +struct CaelWorkspaceContextSurface: Codable, Identifiable { + var id: String { surface } + let surface: String + let owner: String + let context: String + let access: String + let boundary: String +} + +enum CaelJSONValue: Codable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: CaelJSONValue]) + case array([CaelJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([CaelJSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: CaelJSONValue].self) { + self = .object(value) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +struct CaelProfilesListResponse: Codable { + let profiles: [CaelProfileSummary] + let activeProfile: String +} + +struct CaelProfileSummary: Codable, Identifiable, Hashable { + var id: String { name } + let name: String + let path: String + let active: Bool + let exists: Bool + let model: String? + let provider: String? + let description: String? + let displayName: String? + let skillCount: Int + let sessionCount: Int + let hasEnv: Bool + let updatedAt: String? + + var resolvedDisplayName: String { + if let displayName, !displayName.isEmpty { + return displayName + } + return name == "default" ? ConnectionProfile.defaultAgentDisplayName : name + } +} + +struct CaelProfileDetailResponse: Codable { + let profile: CaelProfileDetail +} + +struct CaelProfileMutationResponse: Codable { + let ok: Bool? + let profile: CaelProfileDetail? + let error: String? +} + +struct WorkspaceAgentStartResponse: Codable { + let ok: Bool + let message: String? + let pid: Int? + let error: String? + + var displayMessage: String { + if ok { + return message?.nilIfBlank ?? "Agent runtime accepted the start request." + } + return error?.nilIfBlank ?? "Agent runtime start failed." + } +} + +struct WorkspaceCrewStatusResponse: Codable { + let crew: [WorkspaceCrewMember] + let fetchedAt: Double? +} + +struct WorkspaceCrewMember: Codable, Identifiable, Hashable { + let id: String + let displayName: String + let humanLabel: String? + let role: String + let specialty: String? + let mission: String? + let skills: [String]? + let capabilities: [String]? + let profileFound: Bool + let gatewayState: String + let processAlive: Bool + let model: String + let provider: String + let lastSessionTitle: String? + let lastSessionAt: Double? + let sessionCount: Int + let messageCount: Int + let toolCallCount: Int + let totalTokens: Int + let estimatedCostUsd: Double? + let cronJobCount: Int + let assignedTaskCount: Int +} + +struct CaelProfileDetail: Codable { + let name: String + let path: String + let active: Bool + let config: [String: CaelJSONValue] + let description: String + let displayName: String? + let envPath: String? + let hasEnv: Bool + let sessionsDir: String? + let skillsDir: String? + + var resolvedDisplayName: String { + if let displayName, !displayName.isEmpty { + return displayName + } + return name == "default" ? ConnectionProfile.defaultAgentDisplayName : name + } +} + +struct CaelCommandCenterContract: Codable { + let id: String + let version: String + let generatedAt: String + let principle: String + let primarySurface: String + let mirrorSurface: String + let privateAccess: String + let surfaces: [CaelCommandCenterSurface] +} + +struct CaelCommandCenterSurface: Codable, Identifiable { + let id: String + let label: String + let owner: String + let desktop: String + let web: String + let source: String + let status: String + let description: String +} + +struct CaelIntegrationStatus: Codable { + let ok: Bool + let generatedAt: String + let integrations: [CaelIntegrationCheck] + let policy: [String: String] +} + +struct CaelIntegrationCheck: Codable, Identifiable { + let id: String + let label: String + let status: String + let detail: String + let safeMode: String +} + +struct CaelProviderUsageLimits: Codable { + let ok: Bool + let generatedAt: String + let enabledProviders: [String] + let providers: [CaelProviderUsageCard] +} + +struct CaelProviderUsageCard: Codable, Identifiable { + let id: String + let label: String + let status: String + let plan: String? + let message: String? + let caelConfigured: Bool? + let caelDefault: Bool? + let caelModel: String? + let caelModels: [String]? + let monitorKind: String? + let updatedAt: String + let source: String + let confidence: String + let primary: CaelUsageWindow? + let secondary: CaelUsageWindow? + let tertiary: CaelUsageWindow? + let usageRows: [CaelUsageWindow] + let badges: [CaelUsageBadge] + let creditsRemaining: Double? + let codeReviewRemainingPercent: Double? + let tokenUsage: CaelTokenUsage + let dailyUsage: [CaelDailyUsage] +} + + +struct WorkspaceHermesConfigResponse: Codable { + let ok: Bool? + let activeProvider: String? + let activeModel: String? + let providers: [WorkspaceHermesProviderState]? + let error: String? +} + +struct WorkspaceModelCatalogResponse: Codable { + let ok: Bool? + let object: String? + let data: [WorkspaceModelCatalogEntry]? + let models: [WorkspaceModelCatalogEntry]? + let configuredProviders: [String]? + let source: String? + let streamAcceptedTimeoutMs: Int? + let streamHandoffTimeoutMs: Int? + let error: String? + + var catalogModels: [WorkspaceModelCatalogEntry] { + if let models { return models } + return data ?? [] + } +} + +struct WorkspaceModelCatalogEntry: Codable, Identifiable, Hashable { + let id: String + let name: String? + let provider: String? + let ownedBy: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case provider + case ownedBy = "owned_by" + } +} + +struct WorkspaceModelInfoResponse: Codable { + let ok: Bool? + let provider: String? + let model: String? + let autoContextLength: Int? + let configContextLength: Int? + let effectiveContextLength: Int? + let capabilities: [String: JSONValue]? + let supportsRuntimeSwitching: Bool? + let vanillaAgent: Bool? + let mode: String? + let gatewayMode: String? + let error: String? + + enum CodingKeys: String, CodingKey { + case ok + case provider + case model + case autoContextLength = "auto_context_length" + case configContextLength = "config_context_length" + case effectiveContextLength = "effective_context_length" + case capabilities + case supportsRuntimeSwitching + case vanillaAgent + case mode + case gatewayMode + case error + } +} + +struct WorkspaceContextUsageResponse: Codable { + let ok: Bool + let contextPercent: Double + let maxTokens: Int + let usedTokens: Int + let model: String + let staticTokens: Int + let conversationTokens: Int + let error: String? +} + +struct WorkspaceHermesProviderState: Codable, Identifiable, Hashable { + let id: String + let name: String + let configured: Bool? + let authenticated: Bool? + let available: Bool? + let isDefault: Bool? + let models: [WorkspaceHermesModelState]? +} + +struct WorkspaceHermesModelState: Codable, Identifiable, Hashable { + let id: String + let name: String? +} + +struct WorkspaceHermesConfigPatchResponse: Codable { + let ok: Bool? + let message: String? + let error: String? +} + +struct CaelUsageWindow: Codable, Identifiable { + let id: String + let label: String + let used: Double + let limit: Double + let unit: String + let usedPercent: Double + let remainingPercent: Double + let resetsAt: String? +} + +struct CaelUsageBadge: Codable, Identifiable { + var id: String { "\(label)-\(value)" } + let label: String + let value: String + let color: String? +} + +struct CaelTokenUsage: Codable { + let sessionCostUSD: Double? + let sessionTokens: Double? + let last30DaysCostUSD: Double? + let last30DaysTokens: Double? +} + +struct CaelDailyUsage: Codable, Identifiable { + var id: String { dayKey } + let dayKey: String + let totalTokens: Double? + let costUSD: Double? +} + + +struct CaelCommandCenterSummaryEnvelope: Codable { + let ok: Bool + let generatedAt: String + let source: String + let scope: String + let data: CaelCommandCenterSummary? + let warnings: [String] + let errors: [String] + let links: [CaelCommandCenterEnvelopeLink]? +} + +struct CaelCommandCenterEnvelopeLink: Codable, Identifiable { + var id: String { href } + let label: String + let href: String + let kind: String +} + +struct CaelCommandCenterSummary: Codable { + let version: String + let generatedAt: String + let contract: CaelCommandCenterContract? + let posture: CaelCommandCenterPosture? + let systems: [CaelCommandCenterSystem] + let integrations: [CaelCommandCenterIntegration] + let usage: CaelCommandCenterUsage? + let automations: CaelCommandCenterAutomations? + let brain: CaelCommandCenterBrain? + let actionGates: [CaelCommandCenterActionGate] + let agentRuns: [CaelCommandCenterAgentRun] + let nowNext: [CaelCommandCenterNowNextItem] + let homebaseRecords: CaelCommandCenterHomebaseRecords +} + +struct CaelCommandCenterPosture: Codable { + let host: String + let bind: String + let remoteAccess: String + let auth: String + let publicInternet: String +} + +struct CaelCommandCenterSystem: Codable, Identifiable { + let id: String + let label: String + let ok: Bool + let lane: String + let owner: String + let detail: String + let latencyMs: Double? +} + +struct CaelCommandCenterIntegration: Codable, Identifiable { + let id: String + let label: String + let status: String + let detail: String + let safeMode: String +} + +struct CaelCommandCenterUsage: Codable { + let enabledProviders: [String] + let providers: [CaelCommandCenterUsageProvider] +} + +struct CaelCommandCenterUsageProvider: Codable, Identifiable { + let id: String + let label: String + let status: String + let confidence: String + let monitorKind: String? + let caelDefault: Bool + let caelModel: String? + let primary: CaelCommandCenterUsageWindow? +} + +struct CaelCommandCenterUsageWindow: Codable { + let label: String + let usedPercent: Double + let remainingPercent: Double + let resetsAt: String? +} + +struct CaelCommandCenterAutomations: Codable { + let boundary: String + let instances: [CaelCommandCenterAutomation] +} + +struct CaelCommandCenterAutomation: Codable, Identifiable { + let id: String + let label: String + let ok: Bool + let scope: String + let boundary: String + let failures: Int +} + +struct CaelCommandCenterBrain: Codable { + let sources: [CaelCommandCenterBrainSource] +} + +struct CaelCommandCenterBrainSource: Codable, Identifiable { + let id: String + let label: String + let category: String + let status: String + let writable: Bool +} + +struct CaelCommandCenterActionGate: Codable, Identifiable { + let id: String + let label: String + let source: String + let status: String + let riskLevel: String + let approvalRequired: Bool + let dryRunSupported: Bool + let detail: String +} + +struct CaelCommandCenterAgentRun: Codable, Identifiable { + let id: String + let title: String + let status: String + let updatedAt: String + let source: String + let path: String? +} + +struct CaelCommandCenterNowNextItem: Codable, Identifiable { + let id: String + let label: String + let detail: String + let tone: String + let href: String? +} + +struct CaelCommandCenterHomebaseRecords: Codable { + let status: String + let detail: String + let records: [CaelCommandCenterHomebaseRecord] +} + +struct CaelCommandCenterHomebaseRecord: Codable, Identifiable { + let id: String + let label: String + let kind: String + let updatedAt: String? +} + +struct CaelCommandCenterSectionEnvelope: Codable { + let ok: Bool + let generatedAt: String + let source: String + let scope: String + let data: Payload? + let warnings: [String] + let errors: [String] +} + +struct CaelCommandCenterSectionsSnapshot: Codable { + let actionGates: CaelCommandCenterSectionEnvelope? + let agentRuns: CaelCommandCenterSectionEnvelope? + let automations: CaelCommandCenterSectionEnvelope? + let brain: CaelCommandCenterSectionEnvelope? + let homebaseRecords: CaelCommandCenterSectionEnvelope? + let memoryArtifacts: CaelCommandCenterSectionEnvelope? + let usageLimits: CaelCommandCenterSectionEnvelope? + let vaultRefs: CaelCommandCenterSectionEnvelope? + + var warningCount: Int { + [ + actionGates?.warnings.count, + agentRuns?.warnings.count, + automations?.warnings.count, + brain?.warnings.count, + homebaseRecords?.warnings.count, + memoryArtifacts?.warnings.count, + usageLimits?.warnings.count, + vaultRefs?.warnings.count + ] + .compactMap { $0 } + .reduce(0, +) + } +} + +struct CaelCommandCenterActionGatesSection: Codable { + let total: Int + let approvalRequired: Int + let dryRun: Int + let actions: [CaelCommandCenterActionGateDetail] +} + +struct CaelCommandCenterActionGateDetail: Codable, Identifiable { + let id: String + let label: String + let source: String + let status: String + let riskLevel: String + let approvalRequired: Bool + let dryRunSupported: Bool + let detail: String + let ownerSystem: String + let sideEffects: String + let rollback: String + let href: String? +} + +struct CaelCommandCenterAgentRunsSection: Codable { + let runs: [CaelCommandCenterAgentRunDetail] + let receipts: [CaelCommandCenterPromotionReceipt] +} + +struct CaelCommandCenterAgentRunDetail: Codable, Identifiable { + let id: String + let title: String + let status: String + let updatedAt: String + let source: String + let path: String? + let receiptCount: Int? + let verification: String +} + +struct CaelCommandCenterPromotionReceipt: Codable, Identifiable { + var id: String { path } + let title: String + let path: String + let updatedAt: String + let instance: String +} + +struct CaelCommandCenterAutomationSection: Codable { + let boundary: String + let instances: [CaelCommandCenterAutomationInstance] + let promotionReceipts: [CaelCommandCenterPromotionReceipt] + let guardrails: [String] +} + +struct CaelCommandCenterAutomationInstance: Codable, Identifiable { + let id: String + let label: String + let scope: String + let access: String + let boundary: String + let health: CaelCommandCenterAutomationHealth + let failures: [CaelCommandCenterAutomationFailure] +} + +struct CaelCommandCenterAutomationHealth: Codable { + let ok: Bool + let detail: String + let checkedAt: String + let latencyMs: Double? +} + +struct CaelCommandCenterAutomationFailure: Codable, Identifiable { + var id: String { "\(instance)-\(workflowName)-\(lastSeen)" } + let workflowName: String + let status: String + let lastSeen: String + let count: Int + let instance: String +} + +struct CaelN8nGovernanceStatus: Codable { + let ok: Bool + let generatedAt: String + let boundary: String + let instances: [CaelCommandCenterAutomationInstance] + let promotionReceipts: [CaelCommandCenterPromotionReceipt] + let safeWorkflowCommands: [CaelN8nSafeWorkflowCommand] + let guardrails: [String] +} + +struct CaelN8nSafeWorkflowCommand: Codable, Identifiable { + let id: String + let label: String + let description: String + let owningInstance: String + let riskLevel: String + let approvalRequired: Bool + let dryRunSupported: Bool + let sideEffects: String + let rollback: String + let status: String +} + +struct CaelCommandCenterBrainSection: Codable { + let sources: [CaelCommandCenterBrainSource] + let memoryArtifacts: CaelCommandCenterBrainMemoryArtifacts + let policy: [String] +} + +struct CaelCommandCenterBrainMemoryArtifacts: Codable { + let count: Int + let rootConfigured: Bool + let root: String? +} + +struct CaelCommandCenterMemoryArtifactsSection: Codable { + let root: String + let count: Int + let artifacts: [CaelCommandCenterMemoryArtifact] +} + +struct CaelCommandCenterMemoryArtifact: Codable, Identifiable { + let id: String + let title: String + let path: String + let scope: String + let tenant: String? + let updatedAt: String? + let sensitivity: String + let tags: [String] + let excerpt: String +} + +struct CaelCommandCenterVaultRefsSection: Codable { + let warningCount: Int + let refs: [CaelCommandCenterVaultRef] + let policy: [String] +} + +struct CaelCommandCenterVaultRef: Codable, Identifiable { + let id: String + let displayName: String + let scope: String + let exists: Bool + let lastVerifiedAt: String? + let rotationDueAt: String? + let linkedSystems: [String] + let vaultHref: String? + let secretValue: String? +} + +struct KnowledgeFabricHealthResponse: Decodable { + let ok: Bool? + let endpoint: String? + let configured: Bool? + let warning: String? + let error: String? + + var statusLabel: String { + if ok == true { return "Online" } + if configured == false { return "Not configured" } + return "Needs attention" + } +} + +struct KnowledgeFabricSearchResponse: Decodable { + let ok: Bool? + let endpoint: String? + let memoryScope: String? + let data: KnowledgeFabricPayload? + let scopes: KnowledgeFabricScopedResults? + let error: String? + + var scopedResults: [KnowledgeFabricScopedResult] { + if let scopes { + return [ + scopes.business?.withFallbackScope("business"), + scopes.personal?.withFallbackScope("personal") + ].compactMap { $0 } + } + return [KnowledgeFabricScopedResult( + ok: ok, + tool: nil, + endpoint: endpoint, + memoryScope: memoryScope, + data: data, + error: error, + fallbackScope: memoryScope ?? "memory" + )] + } +} + +struct KnowledgeFabricScopedResults: Decodable { + let business: KnowledgeFabricScopedResult? + let personal: KnowledgeFabricScopedResult? +} + +struct KnowledgeFabricScopedResult: Decodable, Identifiable { + let ok: Bool? + let tool: String? + let endpoint: String? + let memoryScope: String? + let data: KnowledgeFabricPayload? + let error: String? + private let fallbackScope: String? + + init( + ok: Bool?, + tool: String?, + endpoint: String?, + memoryScope: String?, + data: KnowledgeFabricPayload?, + error: String?, + fallbackScope: String? + ) { + self.ok = ok + self.tool = tool + self.endpoint = endpoint + self.memoryScope = memoryScope + self.data = data + self.error = error + self.fallbackScope = fallbackScope + } + + private enum CodingKeys: String, CodingKey { + case ok + case tool + case endpoint + case memoryScope + case data + case error + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.ok = try container.decodeIfPresent(Bool.self, forKey: .ok) + self.tool = try container.decodeIfPresent(String.self, forKey: .tool) + self.endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) + self.memoryScope = try container.decodeIfPresent(String.self, forKey: .memoryScope) + self.data = try container.decodeIfPresent(KnowledgeFabricPayload.self, forKey: .data) + self.error = try container.decodeIfPresent(String.self, forKey: .error) + self.fallbackScope = nil + } + + var id: String { + "\(scopeKey)-\(data?.query ?? data?.document?.canonicalDocID ?? data?.document?.title ?? error ?? "result")" + } + + var scopeKey: String { + (memoryScope ?? fallbackScope ?? "memory").lowercased() + } + + var displayScope: String { + switch scopeKey { + case "business": return "Business / Dev Server" + case "personal": return "Personal / BigMac" + default: return scopeKey.capitalized + } + } + + var summary: String { + if let error, !error.isEmpty { return error } + if let answer = data?.answer, !answer.isEmpty { return answer } + if let document = data?.document { + return document.summary?.nilIfBlank ?? document.title?.nilIfBlank ?? "Document record returned without a summary." + } + return data?.text?.nilIfBlank ?? "No structured Knowledge Fabric payload returned." + } + + func withFallbackScope(_ scope: String) -> KnowledgeFabricScopedResult { + KnowledgeFabricScopedResult( + ok: ok, + tool: tool, + endpoint: endpoint, + memoryScope: memoryScope, + data: data, + error: error, + fallbackScope: scope + ) + } +} + +struct KnowledgeFabricPayload: Decodable { + let status: String? + let answer: String? + let mode: String? + let query: String? + let workspace: String? + let evidence: [KnowledgeFabricEvidence] + let document: KnowledgeFabricDocument? + let text: String? + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let text = try? container.decode(String.self) { + self.status = nil + self.answer = nil + self.mode = nil + self.query = nil + self.workspace = nil + self.evidence = [] + self.document = nil + self.text = text + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.status = try container.decodeIfPresent(String.self, forKey: .status) + self.answer = try container.decodeIfPresent(String.self, forKey: .answer) + self.mode = try container.decodeIfPresent(String.self, forKey: .mode) + self.query = try container.decodeIfPresent(String.self, forKey: .query) + self.workspace = try container.decodeIfPresent(String.self, forKey: .workspace) + self.evidence = try container.decodeIfPresent([KnowledgeFabricEvidence].self, forKey: .evidence) ?? [] + self.document = try container.decodeIfPresent(KnowledgeFabricDocument.self, forKey: .document) + self.text = nil + } + + private enum CodingKeys: String, CodingKey { + case status + case answer + case mode + case query + case workspace + case evidence + case document + } +} + +struct KnowledgeFabricEvidence: Decodable, Identifiable { + let docID: String? + let canonicalDocID: String? + let title: String? + let workspace: String? + let sourceTag: String? + let capturedAt: String? + let snippet: String? + + var id: String { + docID ?? canonicalDocID ?? title ?? snippet ?? "evidence" + } + + private enum CodingKeys: String, CodingKey { + case docID = "doc_id" + case canonicalDocID = "canonical_doc_id" + case title + case workspace + case sourceTag = "source_tag" + case capturedAt = "captured_at" + case snippet + } +} + +struct KnowledgeFabricDocument: Decodable { + let canonicalDocID: String? + let title: String? + let summary: String? + let sourceSystem: String? + + private enum CodingKeys: String, CodingKey { + case canonicalDocID = "canonical_doc_id" + case title + case summary + case sourceSystem = "source_system" + } +} + + +struct WorkspaceMemoryListResponse: Decodable { + let files: [WorkspaceMemoryFile] +} + +struct WorkspaceMemoryFile: Decodable, Identifiable { + var id: String { path } + let path: String + let name: String + let size: Int + let modified: String +} + +struct WorkspaceMemoryReadResponse: Decodable { + let path: String? + let content: String? +} + +struct WorkspaceMemorySearchResponse: Decodable { + let results: [WorkspaceMemorySearchMatch] +} + +struct WorkspaceMemorySearchMatch: Decodable, Identifiable { + var id: String { "\(path):\(line):\(text)" } + let path: String + let line: Int + let text: String +} + +struct WorkspaceKnowledgeListResponse: Decodable { + let pages: [WorkspaceKnowledgePage] + let exists: Bool? + let knowledgeRoot: String? +} + +struct WorkspaceKnowledgePage: Decodable, Identifiable { + var id: String { path } + let path: String + let name: String + let title: String + let type: String? + let domain: String? + let status: String? + let tags: [String] + let summary: String? + let created: String? + let updated: String? + let size: Int + let modified: String + let wikilinks: [String] +} + +struct WorkspaceKnowledgeReadResponse: Decodable { + let page: WorkspaceKnowledgePage? + let content: String? + let backlinks: [String]? +} + +struct WorkspaceKnowledgeSearchResponse: Decodable { + let results: [WorkspaceKnowledgeSearchMatch] +} + +struct WorkspaceKnowledgeSearchMatch: Decodable, Identifiable { + var id: String { "\(path):\(line):\(text)" } + let path: String + let title: String + let line: Int + let text: String +} + +struct WorkspaceSecondBrainSourcesResponse: Decodable { + let ok: Bool + let sources: [WorkspaceSecondBrainSource]? + let error: String? +} + +struct WorkspaceSecondBrainSource: Decodable, Identifiable { + let id: String + let label: String + let category: String + let description: String + let refPrefix: String + let writable: Bool + let exists: Bool + let status: String +} + +struct WorkspaceSecondBrainListResponse: Decodable { + let ok: Bool + let source: WorkspaceSecondBrainSource? + let root: String? + let entries: [WorkspaceSecondBrainEntry]? + let error: String? +} + +struct WorkspaceSecondBrainEntry: Decodable, Identifiable { + var id: String { ref } + let name: String + let path: String + let ref: String + let type: String + let size: Int? + let modifiedAt: String? +} + +struct WorkspaceSecondBrainReadResponse: Decodable { + let ok: Bool + let source: WorkspaceSecondBrainSource? + let path: String? + let ref: String? + let content: String? + let hash: String? + let error: String? +} + +struct WorkspaceSecondBrainWriteResponse: Decodable { + let ok: Bool? + let hash: String? + let error: String? +} + +struct WorkspaceSecondBrainDispatchResponse: Decodable { + let ok: Bool + let status: String? + let operation: String? + let idempotencyKey: String? + let n8n: WorkspaceSecondBrainDispatchTarget? + let error: String? +} + +struct WorkspaceSecondBrainDispatchTarget: Decodable { + let configured: Bool + let endpointLabel: String +} + + +struct WorkspaceTerminalSessionsResponse: Decodable { + let ok: Bool? + let sessions: [WorkspaceTerminalSessionSummary] + let error: String? +} + +struct WorkspaceTerminalSessionSummary: Decodable, Identifiable, Hashable { + let id: String + let label: String? + let createdAt: Double + let cwd: String? + let command: [String] + let cols: Int? + let rows: Int? + let idleTtlMs: Int? +} + +struct WorkspaceTerminalSessionRenameRequest: Encodable { + let action: String + let sessionId: String + let label: String +} + +struct WorkspaceTerminalSessionActionResponse: Decodable { + let ok: Bool? + let session: WorkspaceTerminalSessionSummary? + let error: String? +} + +struct WorkspaceSessionCreateResponse: Decodable { + let ok: Bool? + let sessionKey: String? + let friendlyId: String? + let error: String? +} + +struct WorkspaceSessionSendResponse: Decodable { + let ok: Bool + let sessionKey: String? + let runId: String? + let queued: Bool? + let serverSide: Bool? + let error: String? +} + +struct WorkspaceSessionHistoryResponse: Decodable { + let ok: Bool? + let messages: [SessionMessage] + let sessionKey: String? + let source: String? + let error: String? +} + +struct WorkspaceSessionsResponse: Decodable { + let sessions: [WorkspaceSessionSummary] + let totalCount: Int? + let ok: Bool? + let error: String? + + enum CodingKeys: String, CodingKey { + case sessions + case totalCount = "total_count" + case ok + case error + } + + var sessionSummaries: [SessionSummary] { + sessions.compactMap(\.sessionSummary) + } + + func sessionListPage(offset: Int) -> SessionListPage { + let items = sessionSummaries + return SessionListPage( + ok: ok ?? true, + items: items, + totalCount: totalCount ?? offset + items.count + ) + } +} + +struct WorkspaceSessionSummary: Decodable { + let key: String? + let id: String? + let friendlyId: String? + let title: String? + let label: String? + let derivedTitle: String? + let preview: String? + let model: String? + let startedAt: Double? + let createdAt: Double? + let updatedAt: Double? + let messageCount: Int? + let messageCountSnake: Int? + + enum CodingKeys: String, CodingKey { + case key + case id + case friendlyId + case title + case label + case derivedTitle + case preview + case model + case startedAt + case createdAt + case updatedAt + case messageCount + case messageCountSnake = "message_count" + } + + var sessionSummary: SessionSummary? { + guard let sessionID = firstNonBlank(id, key, friendlyId) else { + return nil + } + + let cleanTitle = Self.cleanDisplayText(firstNonBlank(title, label, derivedTitle)) + let cleanPreview = Self.cleanDisplayText(preview) + let fallbackTitle = Self.fallbackTitle(for: sessionID) + + return SessionSummary( + id: sessionID, + title: cleanTitle ?? fallbackTitle, + model: model?.nilIfBlank, + startedAt: Self.timestamp(from: startedAt ?? createdAt), + lastActive: Self.timestamp(from: updatedAt ?? startedAt ?? createdAt), + messageCount: messageCount ?? messageCountSnake, + preview: cleanPreview + ) + } + + private func firstNonBlank(_ values: String?...) -> String? { + for value in values { + if let text = value?.nilIfBlank { + return text + } + } + return nil + } + + private static func timestamp(from value: Double?) -> SessionTimestamp? { + guard let value else { return nil } + let seconds = value > 9_999_999_999 ? value / 1000.0 : value + return .unixSeconds(seconds) + } + + private static func cleanDisplayText(_ value: String?) -> String? { + guard let value = value?.nilIfBlank else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + if lowered.hasPrefix(" String { + let suffix = sessionID.count > 8 ? String(sessionID.suffix(8)) : sessionID + return "Session \(suffix)" + } +} + +struct WorkspaceSessionActiveRunResponse: Decodable { + let ok: Bool + let run: WorkspaceSessionActiveRun? + let error: String? +} + +struct WorkspaceChatEventsResponse: Decodable { + let ok: Bool + let events: [WorkspaceChatEvent] + let error: String? +} + +struct WorkspaceChatEvent: Decodable, Identifiable { + let event: String + let data: [String: JSONValue] + + var id: String { + let runId = data["runId"]?.stringValue ?? "no-run" + let timestamp = data["timestamp"]?.stringValue ?? data["lastEventAt"]?.stringValue ?? "" + return "\(event)-\(runId)-\(timestamp)-\(data.hashValue)" + } +} + +struct WorkspaceSessionActiveRun: Decodable, Equatable { + let runId: String + let sessionKey: String + let friendlyId: String? + let status: String + let createdAt: Double? + let updatedAt: Double? + let lastEventAt: Double? + let assistantText: String? + let thinkingText: String? + let toolCalls: [WorkspaceSessionRunToolCall] + let lifecycleEvents: [WorkspaceSessionRunLifecycleEvent] + let errorMessage: String? +} + +struct WorkspaceSessionRunToolCall: Decodable, Equatable, Identifiable { + let id: String + let name: String + let phase: String + let preview: String? + let result: String? +} + +struct WorkspaceSessionRunLifecycleEvent: Decodable, Equatable, Identifiable { + let text: String + let emoji: String? + let timestamp: Double? + let isError: Bool + + var id: String { "\(timestamp ?? 0)-\(text)" } +} + + +struct KnowledgeFabricSessionStateResponse: Decodable { + let ok: Bool? + let tool: String? + let endpoint: String? + let data: String? + let error: String? +} + +struct WorkspaceMCPListResponse: Decodable { + let servers: [WorkspaceMCPServer] + let total: Int + let categories: [String]? + let ok: Bool? + let error: String? +} + +struct WorkspaceMCPServer: Decodable, Identifiable { + var id: String { name } + let name: String + let enabled: Bool + let transportType: String + let url: String? + let command: String? + let args: [String] + let authType: String + let toolMode: String + let includeTools: [String] + let excludeTools: [String] + let discoveredToolsCount: Int + let status: String + let lastError: String? + let source: String +} + +struct WorkspaceMCPTestResponse: Decodable { + let ok: Bool + let status: String + let latencyMs: Double? + let discoveredTools: [WorkspaceMCPTool] + let error: String? +} + +struct WorkspaceMCPMutationResponse: Decodable { + let ok: Bool + let server: WorkspaceMCPServer? + let error: String? +} + +struct WorkspaceMCPDiscoverResponse: Decodable { + let ok: Bool + let tools: [WorkspaceMCPTool] + let error: String? +} + +struct WorkspaceMCPLogsResponse: Decodable { + let ok: Bool + let lines: [String] + let error: String? +} + +struct WorkspaceMCPHubSourcesResponse: Decodable { + let ok: Bool? + let sources: [WorkspaceMCPHubSource] + let source: String? + let error: String? +} + +struct WorkspaceMCPHubSource: Decodable, Identifiable { + let id: String + let name: String + let url: String + let trust: String? + let format: String? + let enabled: Bool? + let builtin: Bool? +} + +struct WorkspaceMCPPresetsResponse: Decodable { + let ok: Bool? + let presets: [WorkspaceMCPPreset] + let source: String? + let error: String? +} + +struct WorkspaceMCPPreset: Decodable, Identifiable { + let id: String + let name: String? + let description: String? + let category: String? + let homepage: String? + let tags: [String]? + let template: WorkspaceMCPPresetTemplate? +} + +struct WorkspaceMCPHubSearchResponse: Decodable { + let ok: Bool? + let results: [WorkspaceMCPHubEntry] + let source: String? + let total: Int? + let warnings: [String]? + let error: String? +} + +struct WorkspaceMCPHubEntry: Decodable, Identifiable { + let id: String + let name: String + let description: String? + let source: String? + let homepage: String? + let tags: [String]? + let trust: String? + let template: WorkspaceMCPPresetTemplate? + let installCommand: String? + let installed: Bool? +} + +struct WorkspaceMCPPresetTemplate: Decodable { + let name: String? + let transportType: String? + let command: String? + let args: [String]? + let url: String? + let authType: String? + let toolMode: String? +} + +struct WorkspaceMCPTool: Decodable, Identifiable { + var id: String { name } + let name: String + let description: String? +} + +struct WorkspaceSwarmHealthResponse: Decodable { + let workspaceModel: String? + let apiUrl: String? + let checkedAt: Double? + let workers: [WorkspaceSwarmWorkerHealth] + let summary: WorkspaceSwarmHealthSummary +} + +struct WorkspaceSwarmHealthSummary: Decodable { + let totalWorkers: Int + let wrappersConfigured: Int? + let totalAuthErrors24h: Int + let totalFallbacks24h: Int? + let workersUsingFallback: Int? + let workersPrimaryAuthFailed: Int? + let distinctModels: [String] + let distinctProviders: [String] + let degraded: Bool + let warnings: [String] +} + +struct WorkspaceSwarmWorkerHealth: Decodable, Identifiable { + var id: String { workerId } + let workerId: String + let displayName: String + let humanLabel: String + let role: String + let specialty: String? + let mission: String? + let profileFound: Bool + let wrapperFound: Bool + let model: String + let provider: String + let recentAuthErrors: Int + let recentFallbacks: Int + let lastErrorMessage: String? + let modelAuthStatus: String + let fallbackActive: Bool +} + +struct WorkspaceSwarmRuntimeResponse: Decodable { + let checkedAt: Double + let registryVersion: Int? + let workspaceRoot: String? + let tmuxAvailable: Bool + let mode: String? + let entries: [WorkspaceSwarmRuntimeEntry] +} + +struct WorkspaceSwarmRuntimeEntry: Decodable, Identifiable { + var id: String { workerId } + let workerId: String + let displayName: String + let humanLabel: String + let role: String + let specialty: String? + let mission: String? + let source: String? + let pid: Int? + let startedAt: Double? + let lastOutputAt: Double? + let cwd: String? + let currentTask: String? + let activeTool: String? + let state: String + let phase: String + let checkpointStatus: String + let needsHuman: Bool + let blockedReason: String? + let lastCheckIn: String? + let lastSummary: String? + let nextAction: String? + let lastResult: String? + let assignedTaskCount: Int + let cronJobCount: Int + let tmuxSession: String? + let tmuxAttachable: Bool + let recentLogTail: String? + let logPath: String? + let terminalKind: String? + let profilePath: String + let wrapperPath: String? +} + +struct WorkspaceSwarmMissionsResponse: Decodable { + let ok: Bool + let path: String? + let mission: WorkspaceSwarmMission? + let missions: [WorkspaceSwarmMission] + let reports: [CaelJSONValue] + let fetchedAt: Double? + let error: String? +} + +struct WorkspaceSwarmReportsResponse: Decodable { + let ok: Bool + let fetchedAt: Double? + let missionId: String? + let workerId: String? + let reports: [WorkspaceSwarmReport] + let error: String? +} + +struct WorkspaceSwarmReport: Decodable, Identifiable { + var id: String { "\(missionId):\(assignmentId):\(workerId):\(recordedAt ?? 0)" } + let missionId: String + let assignmentId: String + let workerId: String + let recordedAt: Double? + let stateLabel: String? + let checkpointStatus: String? + let runtimeState: String? + let filesChanged: String? + let commandsRun: String? + let result: String? + let blocker: String? + let nextAction: String? + let source: String? +} + +struct WorkspaceSwarmMission: Decodable, Identifiable { + let id: String + let title: String + let state: String + let createdAt: String? + let updatedAt: String? + let assignments: [WorkspaceSwarmAssignment] +} + +struct WorkspaceSwarmAssignment: Decodable, Identifiable { + let id: String + let workerId: String + let task: String + let state: String + let rationale: String? + let reviewRequired: Bool? +} + +struct WorkspaceSwarmMemoryResponse: Decodable { + let ok: Bool? + let workerId: String? + let kind: String + let root: String? + let path: String? + let files: [WorkspaceSwarmMemoryFile] + let error: String? +} + +struct WorkspaceSwarmMemoryFile: Decodable, Identifiable { + var id: String { path } + let name: String + let path: String + let content: String +} + +struct WorkspaceSwarmMemorySearchResponse: Decodable { + let results: [WorkspaceSwarmMemorySearchResult] + let error: String? +} + +struct WorkspaceSwarmMemorySearchResult: Decodable, Identifiable { + var id: String { "\(path):\(line)" } + let path: String + let line: Int + let score: Double + let snippet: String +} + +struct WorkspaceSwarmWorkerMutationResponse: Decodable { + let workerId: String? + let sessionName: String? + let alreadyRunning: Bool? + let started: Bool? + let wasRunning: Bool? + let killed: Bool? + let runtimePatched: Bool? + let error: String? +} + +struct WorkspaceSwarmDispatchResponse: Decodable { + let ok: Bool? + let missionId: String? + let missionTitle: String? + let results: [WorkspaceSwarmDispatchResult]? + let error: String? +} + +struct WorkspaceSwarmDispatchResult: Decodable, Identifiable { + var id: String { workerId } + let workerId: String + let ok: Bool + let output: String + let error: String? + let durationMs: Double? + let exitCode: Int? + let delivery: String? + let checkpointStatus: String? +} + +struct WorkspaceConductorSpawnResponse: Decodable { + let ok: Bool + let mode: String? + let modeOfficialOotb: Bool? + let modeNote: String? + let missionId: String? + let sessionKey: String? + let sessionKeyPrefix: String? + let jobId: String? + let jobName: String? + let runId: String? + let warnings: [String]? + let assignments: [WorkspaceConductorAssignment]? + let error: String? +} + +struct WorkspaceConductorMissionResponse: Decodable { + let ok: Bool + let mode: String? + let mission: WorkspaceConductorMissionRecord? + let error: String? +} + +struct WorkspaceConductorMissionRecord: Decodable { + let id: String? + let name: String? + let status: String? + let error: String? + let sessionId: String? + let lines: [String]? + let exitCode: Int? + let nativeSwarm: Bool? + let modeOfficialOotb: Bool? + let modeNote: String? + let assignments: [WorkspaceConductorAssignment]? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case status + case error + case sessionId = "session_id" + case lines + case exitCode = "exit_code" + case nativeSwarm + case modeOfficialOotb + case modeNote + case assignments + case updatedAt + } +} + +struct WorkspaceConductorAssignment: Decodable, Identifiable { + var id: String { assignmentId ?? "\(workerId)-\(task)" } + let assignmentId: String? + let workerId: String + let task: String + let state: String? + let rationale: String? + let reviewRequired: Bool? + let direct: Bool? + + enum CodingKeys: String, CodingKey { + case assignmentId + case workerId + case task + case state + case rationale + case reviewRequired + case direct + } +} + +struct WorkspaceConductorStopResponse: Decodable { + let ok: Bool + let deleted: Int? + let stoppedMissions: Int? + let cancelledNativeMissions: Int? + let error: String? +} + +private extension String { + var nilIfBlank: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/HermesDesktop/Models/ConnectionProfile.swift b/Sources/HermesDesktop/Models/ConnectionProfile.swift index 9b5a911..eb3af40 100644 --- a/Sources/HermesDesktop/Models/ConnectionProfile.swift +++ b/Sources/HermesDesktop/Models/ConnectionProfile.swift @@ -1,6 +1,9 @@ import Foundation struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { + static let defaultCaelWorkspaceBaseURL = "http://100.97.216.111:3077" + static let defaultAgentDisplayName = "Cael" + var id: UUID var label: String var sshAlias: String @@ -9,6 +12,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { var sshUser: String var hermesProfile: String? var customHermesHomePath: String? + var caelWorkspaceBaseURL: String? var createdAt: Date var updatedAt: Date var lastConnectedAt: Date? @@ -22,6 +26,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { sshUser: String = "", hermesProfile: String? = nil, customHermesHomePath: String? = nil, + caelWorkspaceBaseURL: String? = nil, createdAt: Date = Date(), updatedAt: Date = Date(), lastConnectedAt: Date? = nil @@ -34,6 +39,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { self.sshUser = sshUser self.hermesProfile = hermesProfile self.customHermesHomePath = customHermesHomePath + self.caelWorkspaceBaseURL = caelWorkspaceBaseURL self.createdAt = createdAt self.updatedAt = updatedAt self.lastConnectedAt = lastConnectedAt @@ -69,6 +75,31 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { return value.normalizedCustomHermesHomePath } + var trimmedCaelWorkspaceBaseURL: String? { + guard let caelWorkspaceBaseURL else { return nil } + let value = caelWorkspaceBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + return value.normalizedCaelWorkspaceBaseURL + } + + var resolvedCaelWorkspaceBaseURL: String { + trimmedCaelWorkspaceBaseURL ?? Self.defaultCaelWorkspaceBaseURL + } + + var commandCenterClientFingerprint: String { + [workspaceScopeFingerprint, resolvedCaelWorkspaceBaseURL].joined(separator: "|") + } + + func caelWorkspaceURLString(path: String) -> String { + let normalizedPath = path.hasPrefix("/") ? path : "/\(path)" + return "\(resolvedCaelWorkspaceBaseURL)\(normalizedPath)" + } + + func caelWorkspaceURL(path: String) -> URL { + let normalizedPath = path.hasPrefix("/") ? path : "/\(path)" + return URL(string: caelWorkspaceURLString(path: normalizedPath)) ?? URL(string: "\(Self.defaultCaelWorkspaceBaseURL)\(normalizedPath)")! + } + var usesCustomHermesHome: Bool { trimmedCustomHermesHomePath != nil } @@ -80,6 +111,13 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { return trimmedHermesProfile ?? "default" } + var agentDisplayName: String { + if usesCustomHermesHome { + return resolvedHermesProfileName + } + return trimmedHermesProfile?.profileDisplayName ?? Self.defaultAgentDisplayName + } + var usesDefaultHermesProfile: Bool { !usesCustomHermesHome && trimmedHermesProfile == nil } @@ -283,6 +321,10 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { } } + if let workspaceURLError = validateCaelWorkspaceBaseURL(caelWorkspaceBaseURL) { + return workspaceURLError + } + if let trimmedCustomHermesHomePath { if trimmedCustomHermesHomePath.containsControlCharacter { return "Custom Hermes home contains unsupported control characters." @@ -303,6 +345,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { copy.sshUser = sshUser.trimmingCharacters(in: .whitespacesAndNewlines) copy.hermesProfile = trimmedHermesProfile copy.customHermesHomePath = trimmedCustomHermesHomePath + copy.caelWorkspaceBaseURL = trimmedCaelWorkspaceBaseURL if let sshPort = sshPort, sshPort <= 0 { copy.sshPort = nil } @@ -312,6 +355,14 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable { } private extension String { + var normalizedCaelWorkspaceBaseURL: String { + var value = self + while value.count > 1, value.hasSuffix("/") { + value.removeLast() + } + return value + } + var normalizedCustomHermesHomePath: String { if self == "/" || self == "~" { return self @@ -351,6 +402,19 @@ private extension String { return trimmed.split(separator: "/").last.map(String.init) ?? trimmed } + var profileDisplayName: String { + let words = split { character in + character == "-" || character == "_" + } + guard !words.isEmpty else { return self } + return words + .map { word in + let lowercased = String(word).lowercased() + return lowercased.prefix(1).uppercased() + lowercased.dropFirst() + } + .joined(separator: " ") + } + var escapedForDoubleQuotedShellArgument: String { replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") @@ -370,6 +434,35 @@ private extension String { } } +private func validateCaelWorkspaceBaseURL(_ rawValue: String?) -> String? { + guard let rawValue else { return nil } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.containsControlCharacter { + return "Workspace URL contains unsupported control characters." + } + + let normalized = trimmed.normalizedCaelWorkspaceBaseURL + guard let components = URLComponents(string: normalized) else { + return "Workspace URL is not valid." + } + + guard let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return "Workspace URL must start with http:// or https://." + } + + guard let host = components.host, !host.isEmpty else { + return "Workspace URL must include a host." + } + + if components.query != nil || components.fragment != nil { + return "Workspace URL cannot include query strings or fragments." + } + + return nil +} + private func validateSSHArgument(_ value: String?, fieldName: String) -> String? { guard let value else { return nil } if value.hasPrefix("-") { diff --git a/Sources/HermesDesktop/Models/CronJobModels.swift b/Sources/HermesDesktop/Models/CronJobModels.swift index a9f37d6..e5c8e54 100644 --- a/Sources/HermesDesktop/Models/CronJobModels.swift +++ b/Sources/HermesDesktop/Models/CronJobModels.swift @@ -1,11 +1,36 @@ import Foundation -struct CronJobListResponse: Codable { - let ok: Bool +struct CronJobListResponse: Decodable { + let ok: Bool? let jobs: [CronJob] } -struct CronJob: Codable, Identifiable, Hashable, OptionalModelDisplayable { + +struct CronJobOutputResponse: Decodable { + let ok: Bool? + let outputs: [CronJobOutput] + let error: String? +} + +struct CronJobOutput: Decodable, Identifiable, Hashable { + var id: String { filename } + let filename: String + let timestamp: String + let content: String + let size: Int + + var displayTitle: String { + filename.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? timestamp : filename + } + + var previewContent: String { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 6000 else { return trimmed.isEmpty ? "No output content captured." : trimmed } + return String(trimmed.prefix(6000)) + "\n..." + } +} + +struct CronJob: Decodable, Identifiable, Hashable, OptionalModelDisplayable { let id: String let name: String let prompt: String @@ -48,7 +73,9 @@ struct CronJob: Codable, Identifiable, Hashable, OptionalModelDisplayable { case lastRunAt = "last_run_at" case lastStatus = "last_status" case lastError = "last_error" + case lastRunError = "last_run_error" case deliveryTarget = "delivery_target" + case deliver case origin case lastDeliveryError = "last_delivery_error" case script @@ -124,8 +151,15 @@ struct CronJob: Codable, Identifiable, Hashable, OptionalModelDisplayable { nextRunAt = try container.decodeIfPresent(Date.self, forKey: .nextRunAt) lastRunAt = try container.decodeIfPresent(Date.self, forKey: .lastRunAt) lastStatus = try container.decodeIfPresent(String.self, forKey: .lastStatus) - lastError = try container.decodeIfPresent(String.self, forKey: .lastError) - deliveryTarget = try container.decodeIfPresent(String.self, forKey: .deliveryTarget) + lastError = try container.decodeIfPresent(String.self, forKey: .lastError) ?? + container.decodeIfPresent(String.self, forKey: .lastRunError) + if let directDeliveryTarget = try container.decodeIfPresent(String.self, forKey: .deliveryTarget) { + deliveryTarget = directDeliveryTarget + } else if let deliverList = try container.decodeIfPresent([String].self, forKey: .deliver) { + deliveryTarget = deliverList.joined(separator: ",") + } else { + deliveryTarget = try container.decodeIfPresent(String.self, forKey: .deliver) + } origin = try container.decodeIfPresent(CronJobOrigin.self, forKey: .origin) lastDeliveryError = try container.decodeIfPresent(String.self, forKey: .lastDeliveryError) script = try container.decodeIfPresent(String.self, forKey: .script) diff --git a/Sources/HermesDesktop/Models/KanbanModels.swift b/Sources/HermesDesktop/Models/KanbanModels.swift index 8da7777..72eb4f6 100644 --- a/Sources/HermesDesktop/Models/KanbanModels.swift +++ b/Sources/HermesDesktop/Models/KanbanModels.swift @@ -66,6 +66,15 @@ struct KanbanOperationResponse: Codable, Sendable { struct KanbanProject: Codable, Identifiable, Hashable, Sendable { static let defaultSlug = "default" + static let workspaceTasksSlug = "workspace-tasks" + static let workspaceTasks = KanbanProject( + slug: workspaceTasksSlug, + name: "Workspace Tasks", + description: "Shared :3077 task board used by the web and mobile workspace.", + icon: "checklist", + color: "blue", + isCurrent: true + ) let slug: String let name: String? @@ -265,6 +274,7 @@ struct KanbanTask: Codable, Identifiable, Hashable, Sendable, TitleIdentifiable let runCount: Int let latestEventAt: Int? let warnings: KanbanTaskWarnings? + let sessionID: String? enum CodingKeys: String, CodingKey { case id @@ -297,6 +307,7 @@ struct KanbanTask: Codable, Identifiable, Hashable, Sendable, TitleIdentifiable case runCount = "run_count" case latestEventAt = "latest_event_at" case warnings + case sessionID = "session_id" } init( @@ -329,7 +340,8 @@ struct KanbanTask: Codable, Identifiable, Hashable, Sendable, TitleIdentifiable eventCount: Int, runCount: Int, latestEventAt: Int?, - warnings: KanbanTaskWarnings? = nil + warnings: KanbanTaskWarnings? = nil, + sessionID: String? = nil ) { self.id = id self.title = title @@ -361,6 +373,7 @@ struct KanbanTask: Codable, Identifiable, Hashable, Sendable, TitleIdentifiable self.runCount = runCount self.latestEventAt = latestEventAt self.warnings = warnings + self.sessionID = sessionID } init(from decoder: Decoder) throws { @@ -395,6 +408,7 @@ struct KanbanTask: Codable, Identifiable, Hashable, Sendable, TitleIdentifiable runCount = try container.decodeIfPresent(Int.self, forKey: .runCount) ?? 0 latestEventAt = try container.decodeIfPresent(Int.self, forKey: .latestEventAt) warnings = try container.decodeIfPresent(KanbanTaskWarnings.self, forKey: .warnings) + sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID) } var resolvedTitle: String { @@ -513,6 +527,7 @@ enum KanbanTaskStatus: Hashable, Codable, Sendable { case todo case ready case running + case review case blocked case done case archived @@ -523,6 +538,7 @@ enum KanbanTaskStatus: Hashable, Codable, Sendable { .todo, .ready, .running, + .review, .blocked, .done, .archived @@ -548,6 +564,8 @@ enum KanbanTaskStatus: Hashable, Codable, Sendable { self = .ready case "running": self = .running + case "review": + self = .review case "blocked": self = .blocked case "done": @@ -569,6 +587,8 @@ enum KanbanTaskStatus: Hashable, Codable, Sendable { "ready" case .running: "running" + case .review: + "review" case .blocked: "blocked" case .done: @@ -590,6 +610,8 @@ enum KanbanTaskStatus: Hashable, Codable, Sendable { "Ready" case .running: "Running" + case .review: + "Review" case .blocked: "Blocked" case .done: diff --git a/Sources/HermesDesktop/Models/RemoteDiscovery.swift b/Sources/HermesDesktop/Models/RemoteDiscovery.swift index 227b78c..7f30664 100644 --- a/Sources/HermesDesktop/Models/RemoteDiscovery.swift +++ b/Sources/HermesDesktop/Models/RemoteDiscovery.swift @@ -29,14 +29,23 @@ struct RemoteHermesProfile: Codable, Identifiable { let path: String let isDefault: Bool let exists: Bool + let displayName: String? var id: String { name } + var displayTitle: String { displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? name } enum CodingKeys: String, CodingKey { case name case path case isDefault = "is_default" case exists + case displayName = "display_name" + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/Sources/HermesDesktop/Models/SessionModels.swift b/Sources/HermesDesktop/Models/SessionModels.swift index 57226c5..901a59c 100644 --- a/Sources/HermesDesktop/Models/SessionModels.swift +++ b/Sources/HermesDesktop/Models/SessionModels.swift @@ -158,11 +158,62 @@ struct SessionMessage: Codable, Identifiable, Hashable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) role = try container.decodeIfPresent(SessionMessageRole.self, forKey: .role) ?? .event - content = try container.decodeIfPresent(String.self, forKey: .content) + content = Self.decodeContent(from: container) timestamp = try container.decodeIfPresent(SessionTimestamp.self, forKey: .timestamp) metadata = try container.decodeIfPresent([String: JSONValue].self, forKey: .metadata) } + private static func decodeContent( + from container: KeyedDecodingContainer + ) -> String? { + if let value = try? container.decodeIfPresent(String.self, forKey: .content) { + return value + } + guard let value = try? container.decodeIfPresent(JSONValue.self, forKey: .content) else { + return nil + } + return contentText(from: value) + } + + private static func contentText(from value: JSONValue) -> String? { + switch value { + case .string(let text): + return text + case .array(let parts): + let text = parts.compactMap(contentPartText).joined(separator: "\n") + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case .object(let object): + return object["text"]?.stringValue ?? + object["thinking"]?.stringValue ?? + object["partialJson"]?.stringValue ?? + value.displayString + case .null: + return nil + default: + return value.stringValue + } + } + + private static func contentPartText(_ value: JSONValue) -> String? { + guard case .object(let object) = value else { + return contentText(from: value) + } + if let text = object["text"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text + } + if let thinking = object["thinking"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !thinking.isEmpty { + return thinking + } + if let partial = object["partialJson"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !partial.isEmpty { + return partial + } + return nil + } + var displayMetadata: [String: JSONValue]? { guard let metadata else { return nil @@ -212,9 +263,11 @@ struct SessionMessageDisplay: Identifiable, Hashable, Sendable { guard let value = displayMetadata[key] else { return nil } return SessionMetadataDisplayItem(key: key, value: value) } - toolSummary = message.role.isToolRole - ? SessionToolMessageSummary(content: message.content) - : nil + if message.role.isToolRole || SessionToolMessageSummary.isToolLikePayload(message.content) { + toolSummary = SessionToolMessageSummary(content: message.content) + } else { + toolSummary = nil + } isStreaming = false } @@ -358,16 +411,37 @@ struct SessionToolMessageSummary: Hashable, Sendable { } let payload = Self.jsonPayload(from: content) + let toolCall = Self.toolCallPayload(from: Self.jsonObject(from: content)) statusKind = Self.statusKind(from: payload) - statusText = Self.statusText(for: statusKind, payload: payload) - title = Self.title(from: payload) ?? L10n.string("Tool output") - preview = Self.preview(from: payload) ?? Self.snippet(from: content) + statusText = toolCall == nil ? Self.statusText(for: statusKind, payload: payload) : nil + title = toolCall?.title ?? Self.title(from: payload) ?? L10n.string("Tool output") + preview = toolCall?.preview ?? Self.preview(from: payload) ?? Self.snippet(from: content) + } + + static func isToolLikePayload(_ content: String?) -> Bool { + guard let content else { return false } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "{" || trimmed.first == "[" else { return false } + guard trimmed.utf8.count <= jsonParseByteLimit else { return false } + guard let object = Self.jsonObject(from: trimmed) else { + return false + } + + if Self.toolCallPayload(from: object) != nil { + return true + } + + guard let payload = object as? [String: Any] else { return false } + return payload["output"] != nil || + payload["error"] != nil || + payload["diff"] != nil || + payload["exit_code"] != nil || + payload["success"] != nil || + payload["files_modified"] != nil } private static func jsonPayload(from content: String) -> [String: Any]? { - guard content.utf8.count <= jsonParseByteLimit, - let data = content.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data), + guard let object = Self.jsonObject(from: content), let payload = object as? [String: Any] else { return nil } @@ -375,6 +449,57 @@ struct SessionToolMessageSummary: Hashable, Sendable { return payload } + private static func jsonObject(from content: String) -> Any? { + guard content.utf8.count <= jsonParseByteLimit, + let data = content.data(using: .utf8) else { + return nil + } + + return try? JSONSerialization.jsonObject(with: data) + } + + private static func toolCallPayload(from object: Any?) -> (title: String, preview: String?)? { + if let payload = object as? [String: Any] { + return Self.toolCallPayload(from: payload) + } + + if let items = object as? [[String: Any]] { + for item in items { + if let result = Self.toolCallPayload(from: item) { + return result + } + } + } + + return nil + } + + private static func toolCallPayload(from payload: [String: Any]?) -> (title: String, preview: String?)? { + guard let payload else { return nil } + + if let type = stringValue(payload["type"])?.lowercased(), + type.contains("function"), + let function = payload["function"] as? [String: Any] { + let name = stringValue(function["name"]) ?? L10n.string("function") + let arguments = stringValue(function["arguments"]) + return ( + L10n.string("Tool call: %@", name), + arguments.flatMap(Self.snippet(from:)) + ) + } + + if let name = stringValue(payload["name"]), + payload["arguments"] != nil || payload["parameters"] != nil { + let rawArguments = stringValue(payload["arguments"]) ?? stringValue(payload["parameters"]) + return ( + L10n.string("Tool call: %@", name), + rawArguments.flatMap(Self.snippet(from:)) + ) + } + + return nil + } + private static func statusKind(from payload: [String: Any]?) -> SessionToolStatusKind { guard let payload else { return .neutral } diff --git a/Sources/HermesDesktop/Models/SessionTUIModels.swift b/Sources/HermesDesktop/Models/SessionTUIModels.swift index 137e519..0aa44cd 100644 --- a/Sources/HermesDesktop/Models/SessionTUIModels.swift +++ b/Sources/HermesDesktop/Models/SessionTUIModels.swift @@ -1,7 +1,6 @@ import Foundation enum SessionDetailMode: String, CaseIterable, Equatable, Sendable { - case transcript case chat } diff --git a/Sources/HermesDesktop/Models/SkillModels.swift b/Sources/HermesDesktop/Models/SkillModels.swift index 3ae67f2..620097f 100644 --- a/Sources/HermesDesktop/Models/SkillModels.swift +++ b/Sources/HermesDesktop/Models/SkillModels.swift @@ -12,6 +12,148 @@ struct SkillDetailResponse: Codable { typealias SkillWriteResponse = SkillDetailResponse +struct WorkspaceSkillCatalogResponse: Codable { + let skills: [WorkspaceSkillItem] + let total: Int? + let page: Int? + let categories: [String]? + let error: String? +} + +struct WorkspaceSkillHubSearchResponse: Codable { + let ok: Bool? + let results: [WorkspaceSkillHubItem] + let source: String? + let total: Int? + let warning: String? + let error: String? +} + +struct WorkspaceSkillActionResponse: Codable { + let ok: Bool? + let error: String? + let command: String? + let message: String? +} + +struct WorkspaceSkillSecurity: Codable, Hashable { + let level: String? + let flags: [String]? + let score: Int? +} + +struct WorkspaceSkillItem: Codable, Identifiable, Hashable { + let id: String + let slug: String? + let name: String? + let description: String? + let author: String? + let triggers: [String]? + let tags: [String]? + let homepage: String? + let category: String? + let icon: String? + let content: String? + let fileCount: Int? + let sourcePath: String? + let installed: Bool? + let enabled: Bool? + let builtin: Bool? + let featuredGroup: String? + let security: WorkspaceSkillSecurity? + let origin: String? + + var resolvedName: String { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedName.isEmpty { return trimmedName } + let trimmedSlug = slug?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedSlug.isEmpty { return trimmedSlug } + return id + } + + var resolvedSlug: String { + let trimmedSlug = slug?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedSlug.isEmpty ? id : trimmedSlug + } + + var resolvedDescription: String? { + let trimmed = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + var resolvedCategory: String { + let trimmed = category?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "Uncategorized" : trimmed + } + + var resolvedOrigin: String { + let trimmed = origin?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "workspace" : trimmed + } + + var isInstalled: Bool { installed ?? false } + var isEnabled: Bool { enabled ?? false } + var isBuiltin: Bool { builtin ?? false } +} + +struct WorkspaceSkillHubItem: Codable, Hashable { + let id: String? + let name: String? + let description: String? + let source: String? + let identifier: String? + let trustLevel: String? + let repo: String? + let path: String? + let tags: [String]? + let installed: Bool? + let author: String? + let homepage: String? + let category: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case source + case identifier + case trustLevel = "trust_level" + case repo + case path + case tags + case installed + case author + case homepage + case category + } + + var resolvedIdentifier: String { + let candidates = [identifier, id, name] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return "skill" + } + + var resolvedName: String { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedName.isEmpty ? resolvedIdentifier : trimmedName + } + + var resolvedDescription: String? { + let trimmed = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + var resolvedSource: String { + let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "hub" : trimmed + } + + var isInstalled: Bool { installed ?? false } +} + struct SkillLocator: Codable, Hashable { let sourceID: String let relativePath: String diff --git a/Sources/HermesDesktop/Models/ToolArtifactModels.swift b/Sources/HermesDesktop/Models/ToolArtifactModels.swift new file mode 100644 index 0000000..b5e4800 --- /dev/null +++ b/Sources/HermesDesktop/Models/ToolArtifactModels.swift @@ -0,0 +1,40 @@ +import Foundation + +struct ToolArtifactSummary: Codable, Identifiable, Hashable, Sendable { + let id: String + let sessionId: String + let messageId: String? + let toolCallId: String? + let toolName: String? + let kind: String + let title: String + let summary: String + let preview: String + let contentSize: Int + let contentPath: String + let createdAt: Double + + var createdDate: Date { + Date(timeIntervalSince1970: createdAt / 1000) + } +} + +struct ToolArtifactDetail: Codable, Identifiable, Hashable, Sendable { + let id: String + let sessionId: String + let messageId: String? + let toolCallId: String? + let toolName: String? + let kind: String + let title: String + let summary: String + let preview: String + let contentSize: Int + let contentPath: String + let createdAt: Double + let content: String + + var createdDate: Date { + Date(timeIntervalSince1970: createdAt / 1000) + } +} diff --git a/Sources/HermesDesktop/Models/WorkspaceFileModels.swift b/Sources/HermesDesktop/Models/WorkspaceFileModels.swift index 3b42789..d10ea7e 100644 --- a/Sources/HermesDesktop/Models/WorkspaceFileModels.swift +++ b/Sources/HermesDesktop/Models/WorkspaceFileModels.swift @@ -2,12 +2,35 @@ import Foundation enum WorkspaceFileLimits { static let maxEditableFileBytes: Int64 = 10 * 1_000_000 + static let maxDesktopUploadBytes: Int = 10 * 1024 * 1024 static func decimalMegabytes(for byteCount: Int64) -> String { String(format: "%.1f MB", Double(byteCount) / 1_000_000) } } +struct WorkspaceFileUploadResult: Codable, Hashable, Sendable { + let ok: Bool + let path: String? + let size: Int? + let contentHash: String? + let error: String? +} + +struct WorkspacePreviewFile: Codable, Hashable, Sendable { + let ok: Bool? + let path: String + let mime: String + let kind: String + let size: Int64 + let contentHash: String? + let content: String? + let contentBase64: String? + + var isText: Bool { kind == "text" } + var isImage: Bool { kind == "image" } +} + struct WorkspaceFileBookmark: Codable, Identifiable, Equatable, Hashable, Sendable { var id: UUID var workspaceScopeFingerprint: String diff --git a/Sources/HermesDesktop/Models/WorkspaceTaskModels.swift b/Sources/HermesDesktop/Models/WorkspaceTaskModels.swift new file mode 100644 index 0000000..f39f9d3 --- /dev/null +++ b/Sources/HermesDesktop/Models/WorkspaceTaskModels.swift @@ -0,0 +1,198 @@ +import Foundation + +struct WorkspaceTasksResponse: Decodable, Sendable { + let tasks: [WorkspaceTask] +} + +struct WorkspaceTaskMutationResponse: Decodable, Sendable { + let task: WorkspaceTask? + let error: String? +} + +struct WorkspaceTaskLaunchResponse: Decodable, Sendable { + let sessionId: String? + let briefing: String? + let task: WorkspaceTask? + let error: String? +} + +struct WorkspaceTaskDeleteResponse: Decodable, Sendable { + let ok: Bool? + let error: String? +} + +struct WorkspaceTask: Codable, Identifiable, Hashable, Sendable { + let id: String + let title: String + let description: String + let column: WorkspaceTaskColumn + let priority: WorkspaceTaskPriority + let assignee: String? + let tags: [String] + let dueDate: String? + let position: Int? + let createdBy: String + let createdAt: String? + let updatedAt: String? + let sessionID: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case column + case priority + case assignee + case tags + case dueDate = "due_date" + case position + case createdBy = "created_by" + case createdAt = "created_at" + case updatedAt = "updated_at" + case sessionID = "session_id" + } + + var kanbanTask: KanbanTask { + KanbanTask( + id: id, + title: title, + body: description, + assignee: assignee, + status: column.kanbanStatus, + priority: priority.kanbanPriority, + createdBy: createdBy, + createdAt: Self.unixSeconds(from: createdAt), + startedAt: nil, + completedAt: column == .done ? Self.unixSeconds(from: updatedAt) : nil, + workspaceKind: .scratch, + workspacePath: nil, + tenant: "workspace", + result: nil, + skills: tags, + spawnFailures: 0, + workerPID: nil, + lastSpawnError: nil, + maxRuntimeSeconds: nil, + maxRetries: nil, + lastHeartbeatAt: Self.unixSeconds(from: updatedAt), + currentRunID: nil, + parentIDs: [], + childIDs: [], + progress: nil, + commentCount: 0, + eventCount: 0, + runCount: 0, + latestEventAt: Self.unixSeconds(from: updatedAt), + sessionID: sessionID + ) + } + + private static func unixSeconds(from value: String?) -> Int? { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + if let number = Double(value) { + return Int(number > 1_000_000_000_000 ? number / 1000 : number) + } + return ISO8601DateFormatter().date(from: value).map { Int($0.timeIntervalSince1970) } + } +} + +enum WorkspaceTaskColumn: String, Codable, CaseIterable, Sendable { + case backlog + case todo + case inProgress = "in_progress" + case review + case blocked + case done + case deleted + + var kanbanStatus: KanbanTaskStatus { + switch self { + case .backlog: + .triage + case .todo: + .ready + case .inProgress: + .running + case .review: + .review + case .blocked: + .blocked + case .done: + .done + case .deleted: + .archived + } + } + + static func fromKanbanStatus(_ status: KanbanTaskStatus) -> WorkspaceTaskColumn { + switch status { + case .triage: + .backlog + case .todo, .ready: + .todo + case .running: + .inProgress + case .review: + .review + case .blocked: + .blocked + case .done: + .done + case .archived: + .deleted + case .other: + .backlog + } + } +} + +enum WorkspaceTaskPriority: String, Codable, Sendable { + case high + case medium + case low + + var kanbanPriority: Int { + switch self { + case .high: + 1 + case .medium: + 0 + case .low: + -1 + } + } + + static func fromKanbanPriority(_ priority: Int) -> WorkspaceTaskPriority { + if priority > 0 { return .high } + if priority < 0 { return .low } + return .medium + } +} + +extension KanbanBoard { + static func workspaceTasks(_ tasks: [WorkspaceTask], includeDone: Bool) -> KanbanBoard { + let visibleTasks = includeDone ? tasks : tasks.filter { $0.column != .done && $0.column != .deleted } + let kanbanTasks = visibleTasks.map(\.kanbanTask) + let assigneeCounts = Dictionary(grouping: kanbanTasks.compactMap(\.assignee), by: { $0 }) + .mapValues(\.count) + let assignees = assigneeCounts + .map { name, count in KanbanAssignee(name: name, onDisk: false, counts: ["total": count]) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + + return KanbanBoard( + databasePath: "/api/hermes-tasks", + hostWide: true, + isInitialized: true, + hasKanbanModule: true, + hasHermesCLI: false, + dispatcher: nil, + latestEventID: nil, + warning: "Shared Workspace Tasks board from :3077. Advanced Hermes Kanban actions remain available on the SSH boards.", + tasks: kanbanTasks, + assignees: assignees, + tenants: ["workspace"], + stats: nil + ) + } +} + diff --git a/Sources/HermesDesktop/Resources/CaelProfile.png b/Sources/HermesDesktop/Resources/CaelProfile.png new file mode 100644 index 0000000..4e654e1 Binary files /dev/null and b/Sources/HermesDesktop/Resources/CaelProfile.png differ diff --git a/Sources/HermesDesktop/Resources/en.lproj/Localizable.strings b/Sources/HermesDesktop/Resources/en.lproj/Localizable.strings index b6d92bf..713cece 100644 --- a/Sources/HermesDesktop/Resources/en.lproj/Localizable.strings +++ b/Sources/HermesDesktop/Resources/en.lproj/Localizable.strings @@ -955,3 +955,11 @@ "Run in chat" = "Run in chat"; "Run in terminal" = "Run in terminal"; "Workspace Sidebar is locked while Chat TUI is active" = "Workspace Sidebar is locked while Chat TUI is active"; +"Chat is ready; Sessions reads persisted conversation history from the host." = "Chat is ready; Sessions reads persisted conversation history from the host."; +"Hermes TUI will create the next session on the host; the conversation will appear here when it is available." = "Hermes TUI will create the next session on the host; the conversation will appear here when it is available."; +"Native chat and session history" = "Native chat and session history"; +"No messages yet" = "No messages yet"; +"Readiness for native chat and the persisted session history read back from the host." = "Readiness for native chat and the persisted session history read back from the host."; +"Select a session to continue the chat and inspect its metadata and last activity." = "Select a session to continue the chat and inspect its metadata and last activity."; +"Session history files" = "Session history files"; +"Unable to load session history" = "Unable to load session history"; diff --git a/Sources/HermesDesktop/Resources/ru.lproj/Localizable.strings b/Sources/HermesDesktop/Resources/ru.lproj/Localizable.strings index 81ec8b1..5ac5fb0 100644 --- a/Sources/HermesDesktop/Resources/ru.lproj/Localizable.strings +++ b/Sources/HermesDesktop/Resources/ru.lproj/Localizable.strings @@ -955,3 +955,11 @@ "Run in chat" = "Run in chat"; "Run in terminal" = "Run in terminal"; "Workspace Sidebar is locked while Chat TUI is active" = "Workspace Sidebar is locked while Chat TUI is active"; +"Chat is ready; Sessions reads persisted conversation history from the host." = "Chat is ready; Sessions reads persisted conversation history from the host."; +"Hermes TUI will create the next session on the host; the conversation will appear here when it is available." = "Hermes TUI will create the next session on the host; the conversation will appear here when it is available."; +"Native chat and session history" = "Native chat and session history"; +"No messages yet" = "No messages yet"; +"Readiness for native chat and the persisted session history read back from the host." = "Readiness for native chat and the persisted session history read back from the host."; +"Select a session to continue the chat and inspect its metadata and last activity." = "Select a session to continue the chat and inspect its metadata and last activity."; +"Session history files" = "Session history files"; +"Unable to load session history" = "Unable to load session history"; diff --git a/Sources/HermesDesktop/Resources/zh-Hans.lproj/Localizable.strings b/Sources/HermesDesktop/Resources/zh-Hans.lproj/Localizable.strings index 274d395..7f3f74d 100644 --- a/Sources/HermesDesktop/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/HermesDesktop/Resources/zh-Hans.lproj/Localizable.strings @@ -955,3 +955,11 @@ "Run in chat" = "Run in chat"; "Run in terminal" = "Run in terminal"; "Workspace Sidebar is locked while Chat TUI is active" = "Workspace Sidebar is locked while Chat TUI is active"; +"Chat is ready; Sessions reads persisted conversation history from the host." = "Chat is ready; Sessions reads persisted conversation history from the host."; +"Hermes TUI will create the next session on the host; the conversation will appear here when it is available." = "Hermes TUI will create the next session on the host; the conversation will appear here when it is available."; +"Native chat and session history" = "Native chat and session history"; +"No messages yet" = "No messages yet"; +"Readiness for native chat and the persisted session history read back from the host." = "Readiness for native chat and the persisted session history read back from the host."; +"Select a session to continue the chat and inspect its metadata and last activity." = "Select a session to continue the chat and inspect its metadata and last activity."; +"Session history files" = "Session history files"; +"Unable to load session history" = "Unable to load session history"; diff --git a/Sources/HermesDesktop/Services/CaelWorkspaceAPIService.swift b/Sources/HermesDesktop/Services/CaelWorkspaceAPIService.swift new file mode 100644 index 0000000..5b6b069 --- /dev/null +++ b/Sources/HermesDesktop/Services/CaelWorkspaceAPIService.swift @@ -0,0 +1,2461 @@ +import Foundation + +final class CaelWorkspaceAPIService: @unchecked Sendable { + private let sshTransport: SSHTransport + + init(sshTransport: SSHTransport) { + self.sshTransport = sshTransport + } + + func loadStatus(connection: ConnectionProfile) async throws -> CaelWorkspaceStatus { + try await loadJSON(connection: connection, path: "/api/cael-status", responseType: CaelWorkspaceStatus.self) + } + + func loadCommandCenterContract(connection: ConnectionProfile) async throws -> CaelCommandCenterContract { + try await loadStatus(connection: connection).contract + } + + func loadCommandCenterSummary(connection: ConnectionProfile) async throws -> CaelCommandCenterSummaryEnvelope { + try await loadJSON(connection: connection, path: "/api/command-center/summary", responseType: CaelCommandCenterSummaryEnvelope.self) + } + + func loadCommandCenterSections(connection: ConnectionProfile) async -> CaelCommandCenterSectionsSnapshot { + async let actionGates: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/action-gates", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let agentRuns: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/agent-runs", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let automations: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/automations", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let brain: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/brain", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let homebaseRecords: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/homebase-records", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let memoryArtifacts: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/memory-artifacts", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let usageLimits: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/usage-limits", + responseType: CaelCommandCenterSectionEnvelope.self + ) + async let vaultRefs: CaelCommandCenterSectionEnvelope? = try? await loadJSON( + connection: connection, + path: "/api/command-center/vault-refs", + responseType: CaelCommandCenterSectionEnvelope.self + ) + + return await CaelCommandCenterSectionsSnapshot( + actionGates: actionGates, + agentRuns: agentRuns, + automations: automations, + brain: brain, + homebaseRecords: homebaseRecords, + memoryArtifacts: memoryArtifacts, + usageLimits: usageLimits, + vaultRefs: vaultRefs + ) + } + + func loadN8nGovernance(connection: ConnectionProfile) async throws -> CaelN8nGovernanceStatus { + try await loadJSON(connection: connection, path: "/api/cael-n8n-governance", responseType: CaelN8nGovernanceStatus.self) + } + + func loadIntegrations(connection: ConnectionProfile) async throws -> CaelIntegrationStatus { + try await loadJSON(connection: connection, path: "/api/integrations/status", responseType: CaelIntegrationStatus.self) + } + + func loadHermesConfig(connection: ConnectionProfile) async throws -> WorkspaceHermesConfigResponse { + try await loadJSON(connection: connection, path: "/api/hermes-config", responseType: WorkspaceHermesConfigResponse.self) + } + + func loadWorkspaceModels(connection: ConnectionProfile) async throws -> WorkspaceModelCatalogResponse { + try await loadJSON(connection: connection, path: "/api/models", responseType: WorkspaceModelCatalogResponse.self) + } + + func loadWorkspaceModelInfo(connection: ConnectionProfile) async throws -> WorkspaceModelInfoResponse { + try await loadJSON(connection: connection, path: "/api/model/info", responseType: WorkspaceModelInfoResponse.self) + } + + func loadWorkspaceContextUsage( + connection: ConnectionProfile, + sessionID: String? = nil + ) async throws -> WorkspaceContextUsageResponse { + let normalizedSessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let path = apiPath( + "/api/context-usage", + queryItems: normalizedSessionID.isEmpty ? [] : [ + URLQueryItem(name: "sessionId", value: normalizedSessionID) + ] + ) + return try await loadJSON(connection: connection, path: path, responseType: WorkspaceContextUsageResponse.self) + } + + @discardableResult + func setDefaultHermesModel(connection: ConnectionProfile, providerID: String, modelID: String) async throws -> WorkspaceHermesConfigPatchResponse { + let response = try await patchJSON( + connection: connection, + path: "/api/hermes-config", + body: WorkspaceHermesSetDefaultModelRequest( + action: "set-default-model", + providerID: providerID, + modelID: modelID + ), + responseType: WorkspaceHermesConfigPatchResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Hermes model config update failed.") + } + return response + } + + func loadProviderUsage(connection: ConnectionProfile, force: Bool = false) async throws -> CaelProviderUsageLimits { + let path = force ? "/api/usage/limits?force=1" : "/api/usage/limits" + return try await loadJSON(connection: connection, path: path, responseType: CaelProviderUsageLimits.self) + } + + func loadWorkspaceTerminalSessions(connection: ConnectionProfile) async throws -> WorkspaceTerminalSessionsResponse { + try await loadJSON(connection: connection, path: "/api/terminal-sessions", responseType: WorkspaceTerminalSessionsResponse.self) + } + + @discardableResult + func renameWorkspaceTerminalSession( + connection: ConnectionProfile, + sessionID: String, + label: String + ) async throws -> WorkspaceTerminalSessionActionResponse { + let response = try await postJSON( + connection: connection, + path: "/api/terminal-sessions", + body: WorkspaceTerminalSessionRenameRequest( + action: "rename", + sessionId: sessionID, + label: label + ), + responseType: WorkspaceTerminalSessionActionResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace terminal rename failed.") + } + return response + } + + func loadWorkspaceSkills( + connection: ConnectionProfile, + tab: String = "installed", + search: String = "", + limit: Int = 30 + ) async throws -> WorkspaceSkillCatalogResponse { + let path = apiPath( + "/api/skills", + queryItems: [ + URLQueryItem(name: "tab", value: tab), + URLQueryItem(name: "search", value: search), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "sort", value: "name") + ] + ) + return try await loadJSON(connection: connection, path: path, responseType: WorkspaceSkillCatalogResponse.self) + } + + func searchWorkspaceSkillsHub( + connection: ConnectionProfile, + query: String, + limit: Int = 20 + ) async throws -> WorkspaceSkillHubSearchResponse { + let path = apiPath( + "/api/skills/hub-search", + queryItems: [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "source", value: "all"), + URLQueryItem(name: "limit", value: String(limit)) + ] + ) + return try await loadJSON(connection: connection, path: path, responseType: WorkspaceSkillHubSearchResponse.self) + } + + @discardableResult + func runWorkspaceSkillAction( + connection: ConnectionProfile, + action: String, + identifier: String, + enabled: Bool? = nil, + category: String? = nil + ) async throws -> WorkspaceSkillActionResponse { + let response = try await postJSON( + connection: connection, + path: "/api/skills", + body: WorkspaceSkillActionRequest( + action: action, + identifier: identifier, + name: identifier, + category: category?.nilIfBlank, + force: false, + enabled: enabled + ), + responseType: WorkspaceSkillActionResponse.self + ) + if let ok = response.ok, ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace skills action failed.") + } + return response + } + + func loadProfiles(connection: ConnectionProfile) async throws -> CaelProfilesListResponse { + try await loadJSON(connection: connection, path: "/api/profiles/list", responseType: CaelProfilesListResponse.self) + } + + func loadCrewStatus(connection: ConnectionProfile) async throws -> WorkspaceCrewStatusResponse { + try await loadJSON(connection: connection, path: "/api/crew-status", responseType: WorkspaceCrewStatusResponse.self) + } + + func readProfile(connection: ConnectionProfile, name: String) async throws -> CaelProfileDetail { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name + let response = try await loadJSON( + connection: connection, + path: "/api/profiles/read?name=\(encodedName)", + responseType: CaelProfileDetailResponse.self + ) + return response.profile + } + + @discardableResult + func createProfile( + connection: ConnectionProfile, + name: String, + cloneFrom: String? = nil, + provider: String? = nil, + model: String? = nil + ) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/create", + body: CaelProfileCreateRequest( + name: name, + cloneFrom: cloneFrom?.nilIfBlank, + model: model?.nilIfBlank, + provider: provider?.nilIfBlank + ), + responseType: CaelProfileMutationResponse.self + ) + } + + @discardableResult + func activateProfile(connection: ConnectionProfile, name: String) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/activate", + body: CaelProfileNameRequest(name: name), + responseType: CaelProfileMutationResponse.self + ) + } + + @discardableResult + func startWorkspaceAgentRuntime(connection: ConnectionProfile) async throws -> WorkspaceAgentStartResponse { + let response = try await postJSON( + connection: connection, + path: "/api/start-agent", + body: EmptyWorkspaceAPIRequest(), + responseType: WorkspaceAgentStartResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not start the agent runtime.") + } + return response + } + + @discardableResult + func renameProfile(connection: ConnectionProfile, oldName: String, newName: String) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/rename", + body: CaelProfileRenameRequest(oldName: oldName, newName: newName), + responseType: CaelProfileMutationResponse.self + ) + } + + + func createWorkspaceChatSession(connection: ConnectionProfile, label: String? = nil, model: String? = nil) async throws -> WorkspaceSessionCreateResponse { + let response = try await postJSON( + connection: connection, + path: "/api/sessions", + body: WorkspaceSessionCreateRequest( + label: label?.nilIfBlank, + model: model?.nilIfBlank + ), + responseType: WorkspaceSessionCreateResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not create a chat session.") + } + guard response.sessionKey?.isEmpty == false || response.friendlyId?.isEmpty == false else { + throw SSHTransportError.invalidResponse("Workspace API did not return a session key for the new chat session.") + } + return response + } + + func loadWorkspaceSessions( + connection: ConnectionProfile, + offset: Int, + limit: Int + ) async throws -> SessionListPage { + let path = apiPath( + "/api/sessions", + queryItems: [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "offset", value: String(offset)) + ] + ) + let response = try await loadJSON( + connection: connection, + path: path, + responseType: WorkspaceSessionsResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load sessions.") + } + return response.sessionListPage(offset: offset) + } + + @discardableResult + func sendWorkspaceSessionMessage( + connection: ConnectionProfile, + sessionKey: String, + message: String, + autoApproveCommands: Bool, + attachments: [WorkspaceChatAttachment] = [] + ) async throws -> WorkspaceSessionSendResponse { + let encodedSession = Self.pathSegment(sessionKey) + let response = try await postJSON( + connection: connection, + path: "/api/chat/threads/\(encodedSession)/turns", + body: WorkspaceSessionSendRequest( + sessionKey: sessionKey, + message: message, + serverSide: true, + autoApproveCommands: autoApproveCommands, + idempotencyKey: UUID().uuidString, + attachments: attachments.isEmpty ? nil : attachments + ), + responseType: WorkspaceSessionSendResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API did not accept the chat message.") + } + return response + } + + func loadWorkspaceSessionHistory( + connection: ConnectionProfile, + sessionKey: String, + limit: Int = 200 + ) async throws -> WorkspaceSessionHistoryResponse { + let path = apiPath( + "/api/session-history", + queryItems: [ + URLQueryItem(name: "key", value: sessionKey), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "includeTools", value: "true") + ] + ) + let response = try await loadJSON( + connection: connection, + path: path, + responseType: WorkspaceSessionHistoryResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load chat history.") + } + return response + } + + func loadWorkspaceSessionActiveRun( + connection: ConnectionProfile, + sessionKey: String + ) async throws -> WorkspaceSessionActiveRunResponse { + let encodedSession = Self.pathSegment(sessionKey) + let response = try await loadJSON( + connection: connection, + path: "/api/sessions/\(encodedSession)/active-run", + responseType: WorkspaceSessionActiveRunResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load active run state.") + } + return response + } + + func tailWorkspaceChatEvents( + connection: ConnectionProfile, + sessionKey: String, + timeoutSeconds: Int = 8, + maxEvents: Int = 80 + ) async throws -> WorkspaceChatEventsResponse { + let encodedSession = Self.pathSegment(sessionKey) + let path = apiPath( + "/api/chat/threads/\(encodedSession)/events", + queryItems: [URLQueryItem(name: "limit", value: String(maxEvents))] + ) + return try await requestChatEventTail( + connection: connection, + path: path, + timeoutSeconds: timeoutSeconds, + maxEvents: maxEvents + ) + } + + @discardableResult + func deleteProfile(connection: ConnectionProfile, name: String) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/delete", + body: CaelProfileNameRequest(name: name), + responseType: CaelProfileMutationResponse.self + ) + } + + @discardableResult + func updateProfileDescription(connection: ConnectionProfile, name: String, description: String) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/update", + body: CaelProfileDescriptionUpdateRequest( + name: name, + patch: CaelProfileDescriptionPatch(description: description) + ), + responseType: CaelProfileMutationResponse.self + ) + } + + @discardableResult + func updateProfileOperationsConfig( + connection: ConnectionProfile, + name: String, + model: String?, + provider: String?, + systemPrompt: String?, + description: String? + ) async throws -> CaelProfileMutationResponse { + try await postJSON( + connection: connection, + path: "/api/profiles/update", + body: CaelProfileOperationsUpdateRequest( + name: name, + patch: CaelProfileOperationsPatch( + model: model?.nilIfBlank, + provider: provider?.nilIfBlank, + systemPrompt: systemPrompt?.nilIfBlank, + description: description?.nilIfBlank + ) + ), + responseType: CaelProfileMutationResponse.self + ) + } + + func listWorkspaceFiles( + connection: ConnectionProfile, + path filePath: String, + maxDepth: Int = 0, + maxEntries: Int = 500 + ) async throws -> RemoteDirectoryListing { + let response = try await loadJSON( + connection: connection, + path: filesAPIPath(action: "list", path: filePath, maxDepth: maxDepth, maxEntries: maxEntries), + responseType: CaelWorkspaceFilesListResponse.self + ) + return response.remoteDirectoryListing(requestedPath: filePath) + } + + func readWorkspaceFile(connection: ConnectionProfile, path filePath: String) async throws -> FileSnapshot { + let response = try await loadJSON( + connection: connection, + path: filesAPIPath(action: "read", path: filePath), + responseType: CaelWorkspaceFileReadResponse.self + ) + guard response.type == "text" else { + throw SSHTransportError.invalidResponse("Workspace API returned a non-text file. Open images and binary artifacts from the web fallback for now.") + } + guard let contentHash = response.contentHash, !contentHash.isEmpty else { + throw SSHTransportError.invalidResponse("Workspace API did not return a content hash for \(filePath).") + } + return FileSnapshot(content: response.content, contentHash: contentHash) + } + + @discardableResult + func writeWorkspaceFile( + connection: ConnectionProfile, + path filePath: String, + content: String, + expectedContentHash: String? + ) async throws -> FileSaveResult { + let response = try await postJSON( + connection: connection, + path: "/api/files", + body: CaelWorkspaceFileWriteRequest( + action: "write", + path: filePath, + content: content, + expectedContentHash: expectedContentHash + ), + responseType: CaelWorkspaceFileWriteResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not save \(filePath).") + } + guard let contentHash = response.contentHash, !contentHash.isEmpty else { + throw SSHTransportError.invalidResponse("Workspace API did not return a saved content hash for \(filePath).") + } + return FileSaveResult(path: response.path ?? filePath, contentHash: contentHash) + } + + @discardableResult + func makeWorkspaceDirectory(connection: ConnectionProfile, path directoryPath: String) async throws -> String { + let response = try await postJSON( + connection: connection, + path: "/api/files", + body: CaelWorkspaceFileMutationRequest( + action: "mkdir", + path: directoryPath, + from: nil, + to: nil + ), + responseType: CaelWorkspaceFileMutationResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not create \(directoryPath).") + } + return response.path ?? directoryPath + } + + @discardableResult + func renameWorkspacePath(connection: ConnectionProfile, from sourcePath: String, to destinationPath: String) async throws -> String { + let response = try await postJSON( + connection: connection, + path: "/api/files", + body: CaelWorkspaceFileMutationRequest( + action: "rename", + path: nil, + from: sourcePath, + to: destinationPath + ), + responseType: CaelWorkspaceFileMutationResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not rename \(sourcePath).") + } + return response.path ?? destinationPath + } + + func deleteWorkspacePath(connection: ConnectionProfile, path targetPath: String) async throws { + let response = try await postJSON( + connection: connection, + path: "/api/files", + body: CaelWorkspaceFileMutationRequest( + action: "delete", + path: targetPath, + from: nil, + to: nil + ), + responseType: CaelWorkspaceFileMutationResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not delete \(targetPath).") + } + } + + @discardableResult + func uploadWorkspaceFile( + connection: ConnectionProfile, + targetPath: String, + fileName: String, + contentBase64: String + ) async throws -> WorkspaceFileUploadResult { + let response = try await postJSON( + connection: connection, + path: "/api/files", + body: CaelWorkspaceFileUploadRequest( + action: "uploadBase64", + path: targetPath, + fileName: fileName, + contentBase64: contentBase64 + ), + responseType: WorkspaceFileUploadResult.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not upload \(fileName).") + } + return response + } + + func loadPreviewFile(connection: ConnectionProfile, path filePath: String) async throws -> WorkspacePreviewFile { + let response = try await loadJSON( + connection: connection, + path: previewFileAPIPath(path: filePath), + responseType: WorkspacePreviewFile.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse("Workspace API could not preview \(filePath).") + } + return response + } + + func loadToolArtifacts(connection: ConnectionProfile, sessionId: String? = nil, limit: Int = 100) async throws -> [ToolArtifactSummary] { + let response = try await loadJSON( + connection: connection, + path: artifactsAPIPath(sessionId: sessionId, limit: limit), + responseType: ToolArtifactsListResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load tool artifacts.") + } + return response.artifacts + } + + func loadToolArtifact(connection: ConnectionProfile, id artifactId: String) async throws -> ToolArtifactDetail { + let encodedId = artifactId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? artifactId + let response = try await loadJSON( + connection: connection, + path: "/api/artifacts/\(encodedId)", + responseType: ToolArtifactDetailResponse.self + ) + guard response.ok, let artifact = response.artifact else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load artifact \(artifactId).") + } + return artifact + } + + func loadWorkspaceTasks(connection: ConnectionProfile, includeDone: Bool = false) async throws -> [WorkspaceTask] { + let includeDoneValue = includeDone ? "true" : "false" + let response = try await loadJSON( + connection: connection, + path: "/api/hermes-tasks?include_done=\(includeDoneValue)", + responseType: WorkspaceTasksResponse.self + ) + return response.tasks + } + + @discardableResult + func createWorkspaceTask(connection: ConnectionProfile, draft: KanbanTaskDraft) async throws -> WorkspaceTask { + let response = try await postJSON( + connection: connection, + path: "/api/hermes-tasks", + body: WorkspaceTaskCreateRequest( + title: draft.normalizedTitle, + description: draft.normalizedBody ?? "", + column: draft.startsInTriage ? .backlog : .todo, + priority: WorkspaceTaskPriority.fromKanbanPriority(draft.priority), + assignee: draft.normalizedAssignee, + tags: draft.skills, + dueDate: nil, + createdBy: connection.resolvedHermesProfileName + ), + responseType: WorkspaceTaskMutationResponse.self + ) + guard let task = response.task else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API did not return a created task.") + } + return task + } + + @discardableResult + func updateWorkspaceTask( + connection: ConnectionProfile, + taskID: String, + title: String? = nil, + description: String? = nil, + column: WorkspaceTaskColumn? = nil, + priority: WorkspaceTaskPriority? = nil, + assignee: String? = nil, + tags: [String]? = nil + ) async throws -> WorkspaceTask { + let response = try await patchJSON( + connection: connection, + path: "/api/hermes-tasks/\(taskID)", + body: WorkspaceTaskUpdateRequest( + title: title, + description: description, + column: column, + priority: priority, + assignee: assignee, + tags: tags + ), + responseType: WorkspaceTaskMutationResponse.self + ) + guard let task = response.task else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API did not return an updated task.") + } + return task + } + + @discardableResult + func moveWorkspaceTask(connection: ConnectionProfile, taskID: String, column: WorkspaceTaskColumn) async throws -> WorkspaceTask { + let response = try await postJSON( + connection: connection, + path: "/api/hermes-tasks/\(taskID)?action=move", + body: WorkspaceTaskMoveRequest(column: column, movedBy: "cael-desktop"), + responseType: WorkspaceTaskMutationResponse.self + ) + guard let task = response.task else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API did not return a moved task.") + } + return task + } + + @discardableResult + func linkWorkspaceTaskSession(connection: ConnectionProfile, taskID: String, sessionID: String?) async throws -> WorkspaceTask { + let response = try await patchJSON( + connection: connection, + path: "/api/hermes-tasks/\(taskID)", + body: WorkspaceTaskSessionLinkRequest(sessionID: sessionID), + responseType: WorkspaceTaskMutationResponse.self + ) + guard let task = response.task else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API did not return a linked task.") + } + return task + } + + @discardableResult + func launchWorkspaceTaskSession(connection: ConnectionProfile, taskID: String) async throws -> WorkspaceTaskLaunchResponse { + let response = try await postJSON( + connection: connection, + path: "/api/hermes-tasks/\(taskID)?action=launch", + body: WorkspaceEmptyRequest(), + responseType: WorkspaceTaskLaunchResponse.self + ) + guard response.error == nil else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not launch this task.") + } + guard response.sessionId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw SSHTransportError.invalidResponse("Workspace API did not return a task session id.") + } + return response + } + + func deleteWorkspaceTask(connection: ConnectionProfile, taskID: String) async throws { + let response = try await deleteJSON( + connection: connection, + path: "/api/hermes-tasks/\(taskID)", + responseType: WorkspaceTaskDeleteResponse.self + ) + guard response.ok == true else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API does not support deleting this shared task.") + } + } + + func loadWorkspaceCronJobs(connection: ConnectionProfile) async throws -> [CronJob] { + let response = try await loadJSON( + connection: connection, + path: "/api/claude-jobs?include_disabled=true&profiles=all", + responseType: CronJobListResponse.self + ) + return response.jobs + } + + @discardableResult + func createWorkspaceCronJob(connection: ConnectionProfile, draft: CronJobDraft) async throws -> CronJobMutationResult { + let response = try await postJSON( + connection: connection, + path: "/api/claude-jobs", + body: WorkspaceCronJobMutationRequest(connection: connection, draft: draft), + responseType: WorkspaceCronJobMutationResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not create the cron job.") + } + return CronJobMutationResult(jobID: response.jobID ?? response.job?.id, job: response.job) + } + + @discardableResult + func updateWorkspaceCronJob(connection: ConnectionProfile, jobID: String, draft: CronJobDraft) async throws -> CronJobMutationResult { + let response = try await patchJSON( + connection: connection, + path: "/api/claude-jobs/\(Self.pathSegment(jobID))", + body: WorkspaceCronJobMutationRequest(connection: connection, draft: draft), + responseType: WorkspaceCronJobMutationResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not update the cron job.") + } + return CronJobMutationResult(jobID: response.jobID ?? response.job?.id ?? jobID, job: response.job) + } + + func pauseWorkspaceCronJob(connection: ConnectionProfile, jobID: String) async throws { + try await runWorkspaceCronJobAction(connection: connection, jobID: jobID, action: "pause") + } + + func resumeWorkspaceCronJob(connection: ConnectionProfile, jobID: String) async throws { + try await runWorkspaceCronJobAction(connection: connection, jobID: jobID, action: "resume") + } + + func triggerWorkspaceCronJob(connection: ConnectionProfile, jobID: String) async throws { + try await runWorkspaceCronJobAction(connection: connection, jobID: jobID, action: "run") + } + + func deleteWorkspaceCronJob(connection: ConnectionProfile, jobID: String) async throws { + let response = try await deleteJSON( + connection: connection, + path: "/api/claude-jobs/\(Self.pathSegment(jobID))", + responseType: WorkspaceCronJobMutationResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not remove the cron job.") + } + } + + func loadWorkspaceCronJobOutputs(connection: ConnectionProfile, jobID: String, limit: Int = 10) async throws -> [CronJobOutput] { + let boundedLimit = max(1, min(limit, 50)) + let response = try await loadJSON( + connection: connection, + path: "/api/claude-jobs/\(Self.pathSegment(jobID))?action=output&limit=\(boundedLimit)", + responseType: CronJobOutputResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not load cron job output.") + } + return response.outputs + } + + private func runWorkspaceCronJobAction(connection: ConnectionProfile, jobID: String, action: String) async throws { + let response = try await postJSON( + connection: connection, + path: "/api/claude-jobs/\(Self.pathSegment(jobID))?action=\(action)", + body: WorkspaceEmptyRequest(), + responseType: WorkspaceCronJobMutationResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Workspace API could not run cron action \(action).") + } + } + + func loadKnowledgeFabricHealth(connection: ConnectionProfile) async throws -> KnowledgeFabricHealthResponse { + try await loadJSON( + connection: connection, + path: "/api/knowledge/fabric/health", + responseType: KnowledgeFabricHealthResponse.self + ) + } + + func searchKnowledgeFabric( + connection: ConnectionProfile, + query: String, + scope: String, + mode: String, + agentSource: String? = nil + ) async throws -> KnowledgeFabricSearchResponse { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedQuery.isEmpty else { + throw SSHTransportError.invalidResponse("Knowledge Fabric search query is required.") + } + + if scope == "both" { + async let business: KnowledgeFabricScopedResult? = try? await searchKnowledgeFabricScope( + connection: connection, + query: normalizedQuery, + scope: "business", + mode: mode, + agentSource: agentSource + ).scopedResults.first?.withFallbackScope("business") + async let personal: KnowledgeFabricScopedResult? = try? await searchKnowledgeFabricScope( + connection: connection, + query: normalizedQuery, + scope: "personal", + mode: mode, + agentSource: agentSource + ).scopedResults.first?.withFallbackScope("personal") + + let results = await (business, personal) + return KnowledgeFabricSearchResponse( + ok: results.0 != nil || results.1 != nil, + endpoint: nil, + memoryScope: nil, + data: nil, + scopes: KnowledgeFabricScopedResults(business: results.0, personal: results.1), + error: results.0 == nil && results.1 == nil ? "Both Knowledge Fabric scopes failed." : nil + ) + } + + return try await searchKnowledgeFabricScope( + connection: connection, + query: normalizedQuery, + scope: scope, + mode: mode, + agentSource: agentSource + ) + } + + func lookupKnowledgeFabricDocument( + connection: ConnectionProfile, + docID: String, + scope: String + ) async throws -> KnowledgeFabricSearchResponse { + let normalizedDocID = docID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDocID.isEmpty else { + throw SSHTransportError.invalidResponse("Knowledge Fabric document id is required.") + } + + if scope == "both" { + async let business: KnowledgeFabricScopedResult? = try? await lookupKnowledgeFabricDocumentScope( + connection: connection, + docID: normalizedDocID, + scope: "business" + ).scopedResults.first?.withFallbackScope("business") + async let personal: KnowledgeFabricScopedResult? = try? await lookupKnowledgeFabricDocumentScope( + connection: connection, + docID: normalizedDocID, + scope: "personal" + ).scopedResults.first?.withFallbackScope("personal") + + let results = await (business, personal) + return KnowledgeFabricSearchResponse( + ok: results.0 != nil || results.1 != nil, + endpoint: nil, + memoryScope: nil, + data: nil, + scopes: KnowledgeFabricScopedResults(business: results.0, personal: results.1), + error: results.0 == nil && results.1 == nil ? "Both Knowledge Fabric document lookups failed." : nil + ) + } + + return try await lookupKnowledgeFabricDocumentScope( + connection: connection, + docID: normalizedDocID, + scope: scope + ) + } + + func recordKnowledgeFabricSessionState( + connection: ConnectionProfile, + summary: String, + memoryScope: String, + agentSource: String?, + sessionId: String?, + project: String? + ) async throws -> KnowledgeFabricSessionStateResponse { + let normalizedSummary = summary.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSummary.isEmpty else { + throw SSHTransportError.invalidResponse("Knowledge Fabric session-state summary is required.") + } + let response = try await postJSON( + connection: connection, + path: "/api/knowledge/fabric/session-state", + body: KnowledgeFabricSessionStateRequest( + summary: normalizedSummary, + memoryScope: memoryScope, + agentSource: agentSource?.nilIfBlank, + sessionId: sessionId?.nilIfBlank, + project: project?.nilIfBlank, + metadata: [ + "client": "Cael Desktop", + "surface": "native-knowledge-fabric-panel" + ] + ), + responseType: KnowledgeFabricSessionStateResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Knowledge Fabric session-state write failed.") + } + return response + } + + + func listMemoryFiles(connection: ConnectionProfile) async throws -> WorkspaceMemoryListResponse { + try await loadJSON(connection: connection, path: "/api/memory/list", responseType: WorkspaceMemoryListResponse.self) + } + + func readMemoryFile(connection: ConnectionProfile, path filePath: String) async throws -> WorkspaceMemoryReadResponse { + try await loadJSON( + connection: connection, + path: apiPath("/api/memory/read", queryItems: [URLQueryItem(name: "path", value: filePath)]), + responseType: WorkspaceMemoryReadResponse.self + ) + } + + func searchMemoryFiles(connection: ConnectionProfile, query: String) async throws -> WorkspaceMemorySearchResponse { + try await loadJSON( + connection: connection, + path: apiPath("/api/memory/search", queryItems: [URLQueryItem(name: "q", value: query)]), + responseType: WorkspaceMemorySearchResponse.self + ) + } + + func listKnowledgePages(connection: ConnectionProfile) async throws -> WorkspaceKnowledgeListResponse { + try await loadJSON(connection: connection, path: "/api/knowledge/list", responseType: WorkspaceKnowledgeListResponse.self) + } + + func readKnowledgePage(connection: ConnectionProfile, path pagePath: String) async throws -> WorkspaceKnowledgeReadResponse { + try await loadJSON( + connection: connection, + path: apiPath("/api/knowledge/read", queryItems: [URLQueryItem(name: "path", value: pagePath)]), + responseType: WorkspaceKnowledgeReadResponse.self + ) + } + + func searchKnowledgePages(connection: ConnectionProfile, query: String) async throws -> WorkspaceKnowledgeSearchResponse { + try await loadJSON( + connection: connection, + path: apiPath("/api/knowledge/search", queryItems: [URLQueryItem(name: "q", value: query)]), + responseType: WorkspaceKnowledgeSearchResponse.self + ) + } + + func listSecondBrainSources(connection: ConnectionProfile) async throws -> WorkspaceSecondBrainSourcesResponse { + try await loadJSON(connection: connection, path: "/api/second-brain/sources", responseType: WorkspaceSecondBrainSourcesResponse.self) + } + + func listSecondBrainEntries(connection: ConnectionProfile, source: String, path folderPath: String) async throws -> WorkspaceSecondBrainListResponse { + try await loadJSON( + connection: connection, + path: apiPath( + "/api/second-brain/list", + queryItems: [ + URLQueryItem(name: "source", value: source), + URLQueryItem(name: "path", value: folderPath) + ] + ), + responseType: WorkspaceSecondBrainListResponse.self + ) + } + + func readSecondBrainFile(connection: ConnectionProfile, source: String, path filePath: String) async throws -> WorkspaceSecondBrainReadResponse { + try await loadJSON( + connection: connection, + path: apiPath( + "/api/second-brain/read", + queryItems: [ + URLQueryItem(name: "source", value: source), + URLQueryItem(name: "path", value: filePath) + ] + ), + responseType: WorkspaceSecondBrainReadResponse.self + ) + } + + @discardableResult + func writeSecondBrainFile( + connection: ConnectionProfile, + source: String, + path filePath: String, + content: String, + expectedHash: String + ) async throws -> WorkspaceSecondBrainWriteResponse { + let response = try await postJSON( + connection: connection, + path: "/api/second-brain/write", + body: WorkspaceSecondBrainWriteRequest( + source: source, + path: filePath, + content: content, + expectedHash: expectedHash + ), + responseType: WorkspaceSecondBrainWriteResponse.self + ) + guard response.ok != false else { + throw SSHTransportError.invalidResponse(response.error ?? "Second Brain write failed.") + } + return response + } + + @discardableResult + func dispatchSecondBrainWorkflow( + connection: ConnectionProfile, + source: String, + path filePath: String?, + operation: String, + hash: String? + ) async throws -> WorkspaceSecondBrainDispatchResponse { + let response = try await postJSON( + connection: connection, + path: "/api/second-brain/dispatch", + body: WorkspaceSecondBrainDispatchRequest( + source: source, + path: filePath, + operation: operation, + hash: hash + ), + responseType: WorkspaceSecondBrainDispatchResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "Second Brain dispatch failed.") + } + return response + } + + private func searchKnowledgeFabricScope( + connection: ConnectionProfile, + query: String, + scope: String, + mode: String, + agentSource: String? + ) async throws -> KnowledgeFabricSearchResponse { + let endpoint = mode == "agent" ? "/api/knowledge/fabric/agent-search" : "/api/knowledge/fabric/search" + return try await postJSON( + connection: connection, + path: endpoint, + body: KnowledgeFabricSearchRequest( + query: query, + memoryScope: scope, + mode: mode == "agent" ? nil : "local", + maxResults: 8, + agentSource: agentSource?.nilIfBlank + ), + responseType: KnowledgeFabricSearchResponse.self + ) + } + + private func lookupKnowledgeFabricDocumentScope( + connection: ConnectionProfile, + docID: String, + scope: String + ) async throws -> KnowledgeFabricSearchResponse { + try await postJSON( + connection: connection, + path: "/api/knowledge/fabric/document-record", + body: KnowledgeFabricDocumentRequest(docId: docID, memoryScope: scope), + responseType: KnowledgeFabricSearchResponse.self + ) + } + + + private func apiPath(_ path: String, queryItems: [URLQueryItem]) -> String { + var components = URLComponents() + components.path = path + components.queryItems = queryItems + return components.string ?? path + } + + func loadSwarmHealth(connection: ConnectionProfile) async throws -> WorkspaceSwarmHealthResponse { + try await loadJSON(connection: connection, path: "/api/swarm-health", responseType: WorkspaceSwarmHealthResponse.self) + } + + func loadSwarmRuntime(connection: ConnectionProfile) async throws -> WorkspaceSwarmRuntimeResponse { + try await loadJSON(connection: connection, path: "/api/swarm-runtime", responseType: WorkspaceSwarmRuntimeResponse.self) + } + + func loadSwarmMissions(connection: ConnectionProfile, limit: Int = 8) async throws -> WorkspaceSwarmMissionsResponse { + try await loadJSON( + connection: connection, + path: apiPath("/api/swarm-missions", queryItems: [URLQueryItem(name: "limit", value: String(limit))]), + responseType: WorkspaceSwarmMissionsResponse.self + ) + } + + func loadSwarmReports( + connection: ConnectionProfile, + workerID: String? = nil, + missionID: String? = nil, + limit: Int = 20 + ) async throws -> WorkspaceSwarmReportsResponse { + var items = [URLQueryItem(name: "limit", value: String(limit))] + if let workerID = workerID?.trimmingCharacters(in: .whitespacesAndNewlines), !workerID.isEmpty { + items.append(URLQueryItem(name: "workerId", value: workerID)) + } + if let missionID = missionID?.trimmingCharacters(in: .whitespacesAndNewlines), !missionID.isEmpty { + items.append(URLQueryItem(name: "missionId", value: missionID)) + } + return try await loadJSON( + connection: connection, + path: apiPath("/api/swarm-reports", queryItems: items), + responseType: WorkspaceSwarmReportsResponse.self + ) + } + + func loadSwarmMemory( + connection: ConnectionProfile, + workerID: String, + kind: String = "profile" + ) async throws -> WorkspaceSwarmMemoryResponse { + try await loadJSON( + connection: connection, + path: apiPath( + "/api/swarm-memory", + queryItems: [ + URLQueryItem(name: "workerId", value: workerID), + URLQueryItem(name: "kind", value: kind) + ] + ), + responseType: WorkspaceSwarmMemoryResponse.self + ) + } + + func searchSwarmMemory( + connection: ConnectionProfile, + workerID: String, + query: String, + scope: String = "worker", + limit: Int = 10 + ) async throws -> WorkspaceSwarmMemorySearchResponse { + try await loadJSON( + connection: connection, + path: apiPath( + "/api/swarm-memory/search", + queryItems: [ + URLQueryItem(name: "workerId", value: workerID), + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "limit", value: String(limit)) + ] + ), + responseType: WorkspaceSwarmMemorySearchResponse.self + ) + } + + @discardableResult + func startSwarmWorkerTmux(connection: ConnectionProfile, workerID: String) async throws -> WorkspaceSwarmWorkerMutationResponse { + let response = try await postJSON( + connection: connection, + path: "/api/swarm-tmux-start", + body: WorkspaceSwarmWorkerRequest(workerId: workerID), + responseType: WorkspaceSwarmWorkerMutationResponse.self + ) + if let error = response.error?.nilIfBlank { + throw SSHTransportError.invalidResponse(error) + } + return response + } + + @discardableResult + func stopSwarmWorkerTmux(connection: ConnectionProfile, workerID: String) async throws -> WorkspaceSwarmWorkerMutationResponse { + let response = try await postJSON( + connection: connection, + path: "/api/swarm-tmux-stop", + body: WorkspaceSwarmWorkerRequest(workerId: workerID), + responseType: WorkspaceSwarmWorkerMutationResponse.self + ) + if let error = response.error?.nilIfBlank { + throw SSHTransportError.invalidResponse(error) + } + return response + } + + @discardableResult + func dispatchSwarmPrompt( + connection: ConnectionProfile, + workerID: String, + prompt: String, + timeoutSeconds: Int = 60, + allowAsync: Bool = false + ) async throws -> WorkspaceSwarmDispatchResponse { + let response = try await postJSON( + connection: connection, + path: "/api/swarm-dispatch", + body: WorkspaceSwarmDispatchRequest( + workerIds: [workerID], + prompt: prompt, + timeoutSeconds: timeoutSeconds, + allowAsync: allowAsync + ), + responseType: WorkspaceSwarmDispatchResponse.self + ) + if let error = response.error?.nilIfBlank { + throw SSHTransportError.invalidResponse(error) + } + return response + } + + @discardableResult + func spawnConductorMission( + connection: ConnectionProfile, + goal: String, + orchestratorModel: String? = nil, + workerModel: String? = nil, + projectsDir: String? = nil, + maxParallel: Int = 1, + supervised: Bool = false + ) async throws -> WorkspaceConductorSpawnResponse { + let response = try await postJSON( + connection: connection, + path: "/api/conductor-spawn", + body: WorkspaceConductorSpawnRequest( + goal: goal, + orchestratorModel: orchestratorModel?.nilIfBlank, + workerModel: workerModel?.nilIfBlank, + projectsDir: projectsDir?.nilIfBlank, + maxParallel: maxParallel, + supervised: supervised + ), + responseType: WorkspaceConductorSpawnResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Conductor mission launch failed.") + } + return response + } + + func loadConductorMission(connection: ConnectionProfile, missionID: String, lines: Int = 400) async throws -> WorkspaceConductorMissionResponse { + let response = try await loadJSON( + connection: connection, + path: apiPath("/api/conductor-spawn", queryItems: [ + URLQueryItem(name: "missionId", value: missionID), + URLQueryItem(name: "lines", value: String(lines)) + ]), + responseType: WorkspaceConductorMissionResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Conductor mission status unavailable.") + } + return response + } + + @discardableResult + func stopConductorMission( + connection: ConnectionProfile, + missionIDs: [String], + sessionKeys: [String] = [] + ) async throws -> WorkspaceConductorStopResponse { + let response = try await postJSON( + connection: connection, + path: "/api/conductor-stop", + body: WorkspaceConductorStopRequest(sessionKeys: sessionKeys, missionIds: missionIDs), + responseType: WorkspaceConductorStopResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "Conductor stop failed.") + } + return response + } + + + func listMCPServers(connection: ConnectionProfile, search: String = "", category: String = "All") async throws -> WorkspaceMCPListResponse { + var queryItems: [URLQueryItem] = [] + if !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + queryItems.append(URLQueryItem(name: "search", value: search)) + } + if category != "All" { + queryItems.append(URLQueryItem(name: "category", value: category)) + } + let path = queryItems.isEmpty ? "/api/mcp" : apiPath("/api/mcp", queryItems: queryItems) + return try await loadJSON(connection: connection, path: path, responseType: WorkspaceMCPListResponse.self) + } + + func testMCPServer(connection: ConnectionProfile, name: String) async throws -> WorkspaceMCPTestResponse { + let response = try await postJSON( + connection: connection, + path: "/api/mcp/test", + body: WorkspaceMCPTestRequest(name: name), + responseType: WorkspaceMCPTestResponse.self + ) + guard response.ok || !response.status.isEmpty else { + throw SSHTransportError.invalidResponse(response.error ?? "MCP test failed.") + } + return response + } + + func discoverMCPServer(connection: ConnectionProfile, server: WorkspaceMCPServer) async throws -> WorkspaceMCPDiscoverResponse { + guard let command = server.command?.nilIfBlank else { + throw SSHTransportError.invalidResponse("MCP discovery currently supports command-backed servers only.") + } + let response = try await postJSON( + connection: connection, + path: "/api/mcp/discover", + body: WorkspaceMCPCreateRequest( + name: server.name, + enabled: server.enabled, + transportType: server.transportType, + command: command, + args: server.args, + authType: server.authType, + toolMode: server.toolMode + ), + responseType: WorkspaceMCPDiscoverResponse.self + ) + guard response.ok else { + throw SSHTransportError.invalidResponse(response.error ?? "MCP discover failed.") + } + return response + } + + func loadMCPServerLogs(connection: ConnectionProfile, name: String, maxLines: Int = 80) async throws -> WorkspaceMCPLogsResponse { + let encodedName = Self.pathSegment(name) + return try await requestMCPLogTail( + connection: connection, + path: "/api/mcp/\(encodedName)/logs", + maxLines: max(1, min(maxLines, 200)) + ) + } + + func loadMCPHubSources(connection: ConnectionProfile) async throws -> WorkspaceMCPHubSourcesResponse { + try await loadJSON(connection: connection, path: "/api/mcp/hub-sources", responseType: WorkspaceMCPHubSourcesResponse.self) + } + + func loadMCPPresets(connection: ConnectionProfile) async throws -> WorkspaceMCPPresetsResponse { + try await loadJSON(connection: connection, path: "/api/mcp/presets", responseType: WorkspaceMCPPresetsResponse.self) + } + + func searchMCPHub(connection: ConnectionProfile, query: String, limit: Int = 12) async throws -> WorkspaceMCPHubSearchResponse { + let queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "source", value: "all"), + URLQueryItem(name: "limit", value: String(limit)) + ] + return try await loadJSON( + connection: connection, + path: apiPath("/api/mcp/hub-search", queryItems: queryItems), + responseType: WorkspaceMCPHubSearchResponse.self + ) + } + + @discardableResult + func createMCPCommandServer( + connection: ConnectionProfile, + name: String, + command: String, + args: [String], + enabled: Bool + ) async throws -> WorkspaceMCPMutationResponse { + let response = try await postJSON( + connection: connection, + path: "/api/mcp", + body: WorkspaceMCPCreateRequest( + name: name, + enabled: enabled, + transportType: "stdio", + command: command, + args: args, + authType: "none", + toolMode: "all" + ), + responseType: WorkspaceMCPMutationResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "MCP create failed.") + } + return response + } + + @discardableResult + func configureMCPServer( + connection: ConnectionProfile, + name: String, + enabled: Bool? = nil, + toolMode: String? = nil, + includeTools: [String]? = nil, + excludeTools: [String]? = nil + ) async throws -> WorkspaceMCPMutationResponse { + let response = try await putJSON( + connection: connection, + path: "/api/mcp/configure", + body: WorkspaceMCPConfigureRequest( + name: name, + enabled: enabled, + toolMode: toolMode, + includeTools: includeTools, + excludeTools: excludeTools + ), + responseType: WorkspaceMCPMutationResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "MCP configure failed.") + } + return response + } + + @discardableResult + func deleteMCPServer(connection: ConnectionProfile, name: String) async throws -> WorkspaceMCPMutationResponse { + let encodedName = Self.pathSegment(name) + let response = try await deleteJSON( + connection: connection, + path: "/api/mcp/\(encodedName)", + responseType: WorkspaceMCPMutationResponse.self + ) + if response.ok == false { + throw SSHTransportError.invalidResponse(response.error ?? "MCP delete failed.") + } + return response + } + + private func filesAPIPath(action: String, path filePath: String, maxDepth: Int? = nil, maxEntries: Int? = nil) -> String { + var components = URLComponents() + components.path = "/api/files" + var queryItems = [ + URLQueryItem(name: "action", value: action), + URLQueryItem(name: "path", value: filePath) + ] + if let maxDepth { + queryItems.append(URLQueryItem(name: "maxDepth", value: String(maxDepth))) + } + if let maxEntries { + queryItems.append(URLQueryItem(name: "maxEntries", value: String(maxEntries))) + } + components.queryItems = queryItems + return components.string ?? "/api/files" + } + + private func artifactsAPIPath(sessionId: String?, limit: Int) -> String { + var components = URLComponents() + components.path = "/api/artifacts" + var queryItems = [URLQueryItem(name: "limit", value: String(limit))] + if let sessionId, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + queryItems.append(URLQueryItem(name: "sessionId", value: sessionId)) + } + components.queryItems = queryItems + return components.string ?? "/api/artifacts" + } + + private func previewFileAPIPath(path filePath: String) -> String { + var components = URLComponents() + components.path = "/api/preview-file" + components.queryItems = [ + URLQueryItem(name: "format", value: "json"), + URLQueryItem(name: "path", value: filePath) + ] + return components.string ?? "/api/preview-file" + } + + private func loadJSON( + connection: ConnectionProfile, + path: String, + responseType: Response.Type + ) async throws -> Response { + try await requestJSON(connection: connection, path: path, method: "GET", body: nil, responseType: responseType) + } + + private func postJSON( + connection: ConnectionProfile, + path: String, + body: Body, + responseType: Response.Type + ) async throws -> Response { + let bodyData = try JSONEncoder().encode(body) + guard let bodyString = String(data: bodyData, encoding: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace API request body was not valid UTF-8.") + } + return try await requestJSON( + connection: connection, + path: path, + method: "POST", + body: bodyString, + responseType: responseType + ) + } + + private func patchJSON( + connection: ConnectionProfile, + path: String, + body: Body, + responseType: Response.Type + ) async throws -> Response { + let bodyData = try JSONEncoder().encode(body) + guard let bodyString = String(data: bodyData, encoding: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace API request body was not valid UTF-8.") + } + return try await requestJSON( + connection: connection, + path: path, + method: "PATCH", + body: bodyString, + responseType: responseType + ) + } + + private func putJSON( + connection: ConnectionProfile, + path: String, + body: Body, + responseType: Response.Type + ) async throws -> Response { + let bodyData = try JSONEncoder().encode(body) + guard let bodyString = String(data: bodyData, encoding: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace API request body was not valid UTF-8.") + } + return try await requestJSON( + connection: connection, + path: path, + method: "PUT", + body: bodyString, + responseType: responseType + ) + } + + private func deleteJSON( + connection: ConnectionProfile, + path: String, + responseType: Response.Type + ) async throws -> Response { + try await requestJSON(connection: connection, path: path, method: "DELETE", body: nil, responseType: responseType) + } + + private func requestMCPLogTail( + connection: ConnectionProfile, + path: String, + maxLines: Int + ) async throws -> WorkspaceMCPLogsResponse { + let payload = try JSONEncoder().encode(CaelWorkspaceMCPLogRequest( + baseURL: connection.resolvedCaelWorkspaceBaseURL, + path: path, + hermesHome: connection.remoteHermesHomePath, + maxLines: maxLines, + timeoutSeconds: 6 + )) + let requestLiteral = String(data: payload, encoding: .utf8) ?? "{}" + let script = """ + import json + import pathlib + import secrets + import socket + import sys + import time + import urllib.error + import urllib.request + + request = json.loads(\(String(reflecting: requestLiteral))) + hermes_home = pathlib.Path.home() / ".hermes" + store_path = hermes_home / "workspace-sessions.json" + now_ms = int(time.time() * 1000) + ttl_ms = 30 * 24 * 60 * 60 * 1000 + expiry = now_ms + ttl_ms + token = secrets.token_hex(32) + + try: + payload = json.loads(store_path.read_text()) if store_path.exists() else {"tokens": {}} + tokens = payload.get("tokens", {}) + if not isinstance(tokens, dict): + tokens = {} + except Exception: + tokens = {} + + tokens = {key: value for key, value in tokens.items() if isinstance(value, int) and value > now_ms} + tokens[token] = expiry + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text(json.dumps({"tokens": tokens})) + try: + store_path.chmod(0o600) + except Exception: + pass + + url = request["baseURL"].rstrip("/") + request["path"] + headers = { + "Accept": "text/event-stream, application/json", + "Cookie": "claude-auth=" + token, + "User-Agent": "CaelDesktop/1.0 native-api", + } + http_request = urllib.request.Request(url, headers=headers, method="GET") + max_lines = int(request.get("maxLines") or 80) + timeout_seconds = float(request.get("timeoutSeconds") or 6) + deadline = time.time() + timeout_seconds + lines = [] + event_name = None + + try: + with urllib.request.urlopen(http_request, timeout=timeout_seconds) as response: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + body = response.read().decode("utf-8", "replace") + data = json.loads(body) if body.strip() else {} + data.setdefault("lines", []) + print(json.dumps(data)) + sys.exit(0) + + while len(lines) < max_lines and time.time() < deadline: + try: + raw = response.readline() + except socket.timeout: + break + if not raw: + break + line = raw.decode("utf-8", "replace").rstrip("\\r\\n") + if line.startswith("event:"): + event_name = line.split(":", 1)[1].strip() + continue + if line.startswith("data:"): + payload_text = line.split(":", 1)[1].strip() + try: + payload = json.loads(payload_text) + except Exception: + payload = {"line": payload_text} + if event_name == "log": + log_line = str(payload.get("line", "")).strip() + if log_line: + lines.append(log_line) + elif event_name == "error": + error_message = payload.get("message") or payload_text + print(json.dumps({"ok": False, "lines": lines, "error": str(error_message)})) + sys.exit(0) + print(json.dumps({"ok": True, "lines": lines})) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + try: + payload = json.loads(body) + except Exception: + payload = {"error": body} + print(json.dumps({ + "ok": False, + "lines": [], + "error": "HTTP %s: %s" % (error.code, payload.get("error") or body), + })) + except Exception as error: + print(json.dumps({"ok": False, "lines": lines, "error": str(error)})) + """ + + let result = try await sshTransport.execute( + on: connection, + remoteCommand: connection.remoteServiceCommand("python3 -"), + standardInput: Data(script.utf8), + allocateTTY: false + ) + try sshTransport.validateSuccessfulExit(result, for: connection) + + guard let data = result.stdout.data(using: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace MCP logs output was not valid UTF-8.") + } + + do { + return try Self.makeDecoder().decode(WorkspaceMCPLogsResponse.self, from: data) + } catch { + throw SSHTransportError.invalidResponse( + "Workspace MCP logs returned JSON that Cael Desktop could not decode: \(error.localizedDescription)" + ) + } + } + + private func requestChatEventTail( + connection: ConnectionProfile, + path: String, + timeoutSeconds: Int, + maxEvents: Int + ) async throws -> WorkspaceChatEventsResponse { + let payload = try JSONEncoder().encode(CaelWorkspaceChatEventsRequest( + baseURL: connection.resolvedCaelWorkspaceBaseURL, + path: path, + hermesHome: connection.remoteHermesHomePath, + timeoutSeconds: max(1, timeoutSeconds), + maxEvents: max(1, maxEvents) + )) + let requestLiteral = String(data: payload, encoding: .utf8) ?? "{}" + let script = """ + import json + import pathlib + import secrets + import socket + import sys + import time + import urllib.error + import urllib.request + + request = json.loads(\(String(reflecting: requestLiteral))) + hermes_home = pathlib.Path.home() / ".hermes" + store_path = hermes_home / "workspace-sessions.json" + now_ms = int(time.time() * 1000) + ttl_ms = 30 * 24 * 60 * 60 * 1000 + expiry = now_ms + ttl_ms + token = secrets.token_hex(32) + + try: + payload = json.loads(store_path.read_text()) if store_path.exists() else {"tokens": {}} + tokens = payload.get("tokens", {}) + if not isinstance(tokens, dict): + tokens = {} + except Exception: + tokens = {} + + tokens = {key: value for key, value in tokens.items() if isinstance(value, int) and value > now_ms} + tokens[token] = expiry + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text(json.dumps({"tokens": tokens})) + try: + store_path.chmod(0o600) + except Exception: + pass + + url = request["baseURL"].rstrip("/") + request["path"] + headers = { + "Accept": "text/event-stream, application/json", + "Cookie": "claude-auth=" + token, + "User-Agent": "CaelDesktop/1.0 native-api", + } + http_request = urllib.request.Request(url, headers=headers, method="GET") + max_events = int(request.get("maxEvents") or 80) + timeout_seconds = float(request.get("timeoutSeconds") or 8) + deadline = time.time() + timeout_seconds + events = [] + event_name = None + data_lines = [] + + def flush_event(): + global event_name, data_lines + if not event_name: + data_lines = [] + return + payload_text = "\\n".join(data_lines).strip() + data_lines = [] + name = event_name + event_name = None + if not payload_text: + return + try: + payload = json.loads(payload_text) + except Exception: + payload = {"raw": payload_text} + if name == "heartbeat": + return + events.append({"event": name, "data": payload if isinstance(payload, dict) else {"value": payload}}) + + try: + with urllib.request.urlopen(http_request, timeout=timeout_seconds) as response: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + body = response.read().decode("utf-8", "replace") + data = json.loads(body) if body.strip() else {} + data.setdefault("events", []) + print(json.dumps(data)) + sys.exit(0) + + while len(events) < max_events and time.time() < deadline: + try: + raw = response.readline() + except socket.timeout: + break + if not raw: + break + line = raw.decode("utf-8", "replace").rstrip("\\r\\n") + if line == "": + flush_event() + continue + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line.split(":", 1)[1].strip() + continue + if line.startswith("data:"): + data_lines.append(line.split(":", 1)[1].strip()) + flush_event() + print(json.dumps({"ok": True, "events": events})) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + try: + payload = json.loads(body) + except Exception: + payload = {"error": body} + print(json.dumps({ + "ok": False, + "events": events, + "error": "HTTP %s: %s" % (error.code, payload.get("error") or body), + })) + except Exception as error: + print(json.dumps({"ok": False, "events": events, "error": str(error)})) + """ + + let result = try await sshTransport.execute( + on: connection, + remoteCommand: connection.remoteServiceCommand("python3 -"), + standardInput: Data(script.utf8), + allocateTTY: false + ) + try sshTransport.validateSuccessfulExit(result, for: connection) + + guard let data = result.stdout.data(using: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace chat events output was not valid UTF-8.") + } + + do { + return try Self.makeDecoder().decode(WorkspaceChatEventsResponse.self, from: data) + } catch { + throw SSHTransportError.invalidResponse( + "Workspace chat events returned JSON that Cael Desktop could not decode: \(error.localizedDescription)" + ) + } + } + + private func requestJSON( + connection: ConnectionProfile, + path: String, + method: String, + body: String?, + responseType: Response.Type + ) async throws -> Response { + let payload = try JSONEncoder().encode(CaelWorkspaceAPIRequest( + baseURL: connection.resolvedCaelWorkspaceBaseURL, + path: path, + hermesHome: connection.remoteHermesHomePath, + method: method, + body: body + )) + let requestLiteral = String(data: payload, encoding: .utf8) ?? "{}" + let script = """ + import json + import os + import pathlib + import secrets + import sys + import time + import urllib.error + import urllib.request + + request = json.loads(\(String(reflecting: requestLiteral))) + method = str(request.get("method") or "GET").upper() + body = request.get("body") + data = body.encode("utf-8") if isinstance(body, str) else None + hermes_home = pathlib.Path.home() / ".hermes" + store_path = hermes_home / "workspace-sessions.json" + now_ms = int(time.time() * 1000) + ttl_ms = 30 * 24 * 60 * 60 * 1000 + expiry = now_ms + ttl_ms + token = secrets.token_hex(32) + + try: + payload = json.loads(store_path.read_text()) if store_path.exists() else {"tokens": {}} + tokens = payload.get("tokens", {}) + if not isinstance(tokens, dict): + tokens = {} + except Exception: + tokens = {} + + tokens = {key: value for key, value in tokens.items() if isinstance(value, int) and value > now_ms} + tokens[token] = expiry + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text(json.dumps({"tokens": tokens})) + try: + store_path.chmod(0o600) + except Exception: + pass + + url = request["baseURL"].rstrip("/") + request["path"] + headers = { + "Accept": "application/json", + "Cookie": "claude-auth=" + token, + "User-Agent": "CaelDesktop/1.0 native-api", + } + if data is not None: + headers["Content-Type"] = "application/json" + http_request = urllib.request.Request( + url, + data=data, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen(http_request, timeout=20) as response: + sys.stdout.write(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + print(json.dumps({"ok": False, "error": "HTTP %s: %s" % (error.code, body)})) + sys.exit(1) + """ + + let result = try await sshTransport.execute( + on: connection, + remoteCommand: connection.remoteServiceCommand("python3 -"), + standardInput: Data(script.utf8), + allocateTTY: false + ) + try sshTransport.validateSuccessfulExit(result, for: connection) + + guard let data = result.stdout.data(using: .utf8) else { + throw SSHTransportError.invalidResponse("Workspace API output was not valid UTF-8.") + } + + do { + return try Self.makeDecoder().decode(Response.self, from: data) + } catch { + throw SSHTransportError.invalidResponse( + "Workspace API returned JSON that Cael Desktop could not decode: \(error.localizedDescription)" + ) + } + } + + private static func pathSegment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = ISO8601DateFormatter.fractionalSecondsFormatter().date(from: value) { + return date + } + if let date = ISO8601DateFormatter().date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO-8601 date: \(value)" + ) + } + return decoder + } +} + +private struct CaelWorkspaceAPIRequest: Encodable { + let baseURL: String + let path: String + let hermesHome: String + let method: String + let body: String? +} + +private struct CaelWorkspaceMCPLogRequest: Encodable { + let baseURL: String + let path: String + let hermesHome: String + let maxLines: Int + let timeoutSeconds: Int +} + +private struct CaelWorkspaceChatEventsRequest: Encodable { + let baseURL: String + let path: String + let hermesHome: String + let timeoutSeconds: Int + let maxEvents: Int +} + +private struct WorkspaceSkillActionRequest: Encodable { + let action: String + let identifier: String + let name: String + let category: String? + let force: Bool + let enabled: Bool? +} + +private struct EmptyWorkspaceAPIRequest: Encodable {} + +private struct WorkspaceHermesSetDefaultModelRequest: Encodable { + let action: String + let providerID: String + let modelID: String + + enum CodingKeys: String, CodingKey { + case action + case providerID = "providerId" + case modelID = "modelId" + } +} + + +private struct CaelProfileNameRequest: Encodable { + let name: String +} + +private struct CaelProfileCreateRequest: Encodable { + let name: String + let cloneFrom: String? + let model: String? + let provider: String? +} + +private struct CaelProfileRenameRequest: Encodable { + let oldName: String + let newName: String +} + +private struct CaelProfileDescriptionPatch: Encodable { + let description: String +} + +private struct CaelProfileDescriptionUpdateRequest: Encodable { + let name: String + let patch: CaelProfileDescriptionPatch +} + +private struct CaelProfileOperationsPatch: Encodable { + let model: String? + let provider: String? + let systemPrompt: String? + let description: String? + + private enum CodingKeys: String, CodingKey { + case model + case provider + case systemPrompt = "system_prompt" + case description + } +} + +private struct CaelProfileOperationsUpdateRequest: Encodable { + let name: String + let patch: CaelProfileOperationsPatch +} + + +private struct WorkspaceSessionCreateRequest: Encodable { + let label: String? + let model: String? +} + +private struct WorkspaceSessionSendRequest: Encodable { + let sessionKey: String + let message: String + let serverSide: Bool + let autoApproveCommands: Bool + let idempotencyKey: String + let attachments: [WorkspaceChatAttachment]? +} + +private struct KnowledgeFabricSearchRequest: Encodable { + let query: String + let memoryScope: String + let mode: String? + let maxResults: Int + let agentSource: String? +} + +private struct KnowledgeFabricDocumentRequest: Encodable { + let docId: String + let memoryScope: String +} + +private struct KnowledgeFabricSessionStateRequest: Encodable { + let summary: String + let memoryScope: String + let agentSource: String? + let sessionId: String? + let project: String? + let metadata: [String: String] +} + + + +private struct WorkspaceMCPTestRequest: Encodable { + let name: String +} + +private struct WorkspaceMCPCreateRequest: Encodable { + let name: String + let enabled: Bool + let transportType: String + let command: String + let args: [String] + let authType: String + let toolMode: String +} + +private struct WorkspaceMCPConfigureRequest: Encodable { + let name: String + let enabled: Bool? + let toolMode: String? + let includeTools: [String]? + let excludeTools: [String]? +} + +private struct WorkspaceSwarmWorkerRequest: Encodable { + let workerId: String +} + +private struct WorkspaceSwarmDispatchRequest: Encodable { + let workerIds: [String] + let prompt: String + let timeoutSeconds: Int + let allowAsync: Bool +} + +private struct WorkspaceConductorSpawnRequest: Encodable { + let goal: String + let orchestratorModel: String? + let workerModel: String? + let projectsDir: String? + let maxParallel: Int + let supervised: Bool +} + +private struct WorkspaceConductorStopRequest: Encodable { + let sessionKeys: [String] + let missionIds: [String] +} + +private struct WorkspaceSecondBrainWriteRequest: Encodable { + let source: String + let path: String + let content: String + let expectedHash: String +} + +private struct WorkspaceSecondBrainDispatchRequest: Encodable { + let source: String + let path: String? + let operation: String + let hash: String? +} + +private struct CaelWorkspaceFilesListResponse: Decodable { + let root: String? + let base: String? + let entries: [CaelWorkspaceFileEntry] + + func remoteDirectoryListing(requestedPath: String) -> RemoteDirectoryListing { + let resolvedPath = Self.absolutePath(base: base, path: root ?? requestedPath) + let parent = Self.parentPath(for: resolvedPath, base: base) + let mappedEntries = entries.map { $0.remoteDirectoryEntry(base: base) } + + return RemoteDirectoryListing( + requestedPath: requestedPath, + resolvedPath: resolvedPath, + displayPath: resolvedPath, + parentPath: parent, + parentDisplayPath: parent, + entries: mappedEntries, + totalEntryCount: mappedEntries.count, + isTruncated: false + ) + } + + static func absolutePath(base: String?, path candidate: String) -> String { + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") { + return trimmed + } + guard let base = base?.trimmingCharacters(in: .whitespacesAndNewlines), !base.isEmpty else { + return trimmed.isEmpty ? "." : trimmed + } + let normalizedBase = base.hasSuffix("/") ? String(base.dropLast()) : base + guard !trimmed.isEmpty else { return normalizedBase } + return "\(normalizedBase)/\(trimmed)" + } + + static func parentPath(for resolvedPath: String, base: String?) -> String? { + let normalizedBase = base?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingTrailingSlash + let normalizedPath = resolvedPath.trimmingTrailingSlash + if let normalizedBase, normalizedPath == normalizedBase { + return nil + } + let parent = (normalizedPath as NSString).deletingLastPathComponent + guard !parent.isEmpty, parent != normalizedPath else { return nil } + return parent + } +} + +private struct CaelWorkspaceFileEntry: Decodable { + let name: String + let path: String + let type: String + let size: Int64? + let modifiedAt: String? + + func remoteDirectoryEntry(base: String?) -> RemoteDirectoryEntry { + let absolutePath = CaelWorkspaceFilesListResponse.absolutePath(base: base, path: path) + return RemoteDirectoryEntry( + name: name, + path: absolutePath, + displayPath: absolutePath, + kind: type == "folder" ? .directory : .file, + size: size, + modifiedAt: Self.modifiedTimestamp(from: modifiedAt), + isReadable: true, + isWritable: true, + isSymlink: false + ) + } + + private static func modifiedTimestamp(from value: String?) -> Double? { + guard let value else { return nil } + return ISO8601DateFormatter().date(from: value)?.timeIntervalSince1970 + } +} + +private struct WorkspaceTaskCreateRequest: Encodable { + let title: String + let description: String + let column: WorkspaceTaskColumn + let priority: WorkspaceTaskPriority + let assignee: String? + let tags: [String] + let dueDate: String? + let createdBy: String + + enum CodingKeys: String, CodingKey { + case title + case description + case column + case priority + case assignee + case tags + case dueDate = "due_date" + case createdBy = "created_by" + } +} + +private struct WorkspaceTaskUpdateRequest: Encodable { + let title: String? + let description: String? + let column: WorkspaceTaskColumn? + let priority: WorkspaceTaskPriority? + let assignee: String? + let tags: [String]? +} + +private struct WorkspaceTaskSessionLinkRequest: Encodable { + let sessionID: String? + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let sessionID { + try container.encode(sessionID, forKey: .sessionID) + } else { + try container.encodeNil(forKey: .sessionID) + } + } +} + +private struct WorkspaceTaskMoveRequest: Encodable { + let column: WorkspaceTaskColumn + let movedBy: String + + enum CodingKeys: String, CodingKey { + case column + case movedBy = "moved_by" + } +} + +struct CronJobMutationResult { + let jobID: String? + let job: CronJob? +} + +private struct WorkspaceEmptyRequest: Encodable {} + +private struct WorkspaceCronJobMutationRequest: Encodable { + let profile: String + let name: String + let prompt: String + let input: String + let schedule: String + let deliver: [String] + let skills: [String] + let model: String? + let provider: String? + let baseURL: String? + let timezone: String? + let script: String? + let workdir: String? + let noAgent: Bool + + enum CodingKeys: String, CodingKey { + case profile + case name + case prompt + case input + case schedule + case deliver + case skills + case model + case provider + case baseURL = "base_url" + case timezone + case script + case workdir + case noAgent = "no_agent" + } + + init(connection: ConnectionProfile, draft: CronJobDraft) { + let prompt = draft.normalizedPrompt + self.profile = connection.cliHermesProfileName ?? connection.resolvedHermesProfileName + self.name = draft.normalizedName + self.prompt = prompt + self.input = prompt + self.schedule = draft.schedule.expression ?? "" + self.deliver = draft.normalizedDeliveryTarget.map { [$0] } ?? [] + self.skills = draft.normalizedSkills + self.model = draft.normalizedModel + self.provider = draft.normalizedProvider + self.baseURL = draft.normalizedBaseURL + self.timezone = draft.normalizedTimezone + self.script = draft.normalizedScript + self.workdir = draft.normalizedWorkdir + self.noAgent = draft.noAgent + } +} + +private struct WorkspaceCronJobMutationResponse: Decodable { + let ok: Bool? + let job: CronJob? + let jobID: String? + let error: String? + + enum CodingKeys: String, CodingKey { + case ok + case job + case jobID = "jobId" + case error + } +} + +private struct CaelWorkspaceFileReadResponse: Decodable { + let type: String + let path: String? + let content: String + let contentHash: String? +} + +private struct CaelWorkspaceFileWriteRequest: Encodable { + let action: String + let path: String + let content: String + let expectedContentHash: String? +} + +private struct CaelWorkspaceFileWriteResponse: Decodable { + let ok: Bool + let path: String? + let contentHash: String? + let error: String? +} + +private struct CaelWorkspaceFileMutationRequest: Encodable { + let action: String + let path: String? + let from: String? + let to: String? +} + +private struct CaelWorkspaceFileMutationResponse: Decodable { + let ok: Bool + let path: String? + let error: String? +} + +private struct CaelWorkspaceFileUploadRequest: Encodable { + let action: String + let path: String + let fileName: String + let contentBase64: String +} + +private struct ToolArtifactsListResponse: Decodable { + let ok: Bool + let artifacts: [ToolArtifactSummary] + let error: String? +} + +private struct ToolArtifactDetailResponse: Decodable { + let ok: Bool + let artifact: ToolArtifactDetail? + let error: String? +} + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var trimmingTrailingSlash: String { + var result = self + while result.count > 1, result.hasSuffix("/") { + result.removeLast() + } + return result + } +} diff --git a/Sources/HermesDesktop/Services/CommandCenterSnapshotStore.swift b/Sources/HermesDesktop/Services/CommandCenterSnapshotStore.swift new file mode 100644 index 0000000..e4b80a9 --- /dev/null +++ b/Sources/HermesDesktop/Services/CommandCenterSnapshotStore.swift @@ -0,0 +1,139 @@ +import CryptoKit +import Foundation + +struct CaelCommandCenterCachedSnapshot: Codable { + let workspaceScopeFingerprint: String + let workspaceBaseURL: String + let cachedAt: Date + let summaryEnvelope: CaelCommandCenterSummaryEnvelope? + let sections: CaelCommandCenterSectionsSnapshot? +} + +final class CaelCommandCenterSnapshotStore { + private let fileManager: FileManager + private let cacheDirectoryURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(paths: AppPaths) { + self.fileManager = paths.fileManager + self.cacheDirectoryURL = paths.applicationSupportURL.appendingPathComponent( + "CommandCenterSnapshots", + isDirectory: true + ) + self.encoder = JSONEncoder() + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.decoder = JSONDecoder() + } + + func load(for connection: ConnectionProfile) -> CaelCommandCenterCachedSnapshot? { + let url = snapshotURL(for: connection) + guard let data = try? Data(contentsOf: url) else { return nil } + guard let snapshot = try? decoder.decode(CaelCommandCenterCachedSnapshot.self, from: data) else { return nil } + guard snapshot.workspaceScopeFingerprint == connection.workspaceScopeFingerprint else { return nil } + guard snapshot.workspaceBaseURL == connection.resolvedCaelWorkspaceBaseURL else { return nil } + return snapshot + } + + func save( + summaryEnvelope: CaelCommandCenterSummaryEnvelope?, + sections: CaelCommandCenterSectionsSnapshot?, + for connection: ConnectionProfile + ) throws { + guard summaryEnvelope?.data != nil || sections?.hasAnyPayload == true else { return } + try ensureCacheDirectory() + + let snapshot = CaelCommandCenterCachedSnapshot( + workspaceScopeFingerprint: connection.workspaceScopeFingerprint, + workspaceBaseURL: connection.resolvedCaelWorkspaceBaseURL, + cachedAt: Date(), + summaryEnvelope: summaryEnvelope, + sections: sections?.sanitizedForLocalCache + ) + let data = try encoder.encode(snapshot) + try data.write(to: snapshotURL(for: connection), options: [.atomic]) + } + + private func ensureCacheDirectory() throws { + let attributes: [FileAttributeKey: Any] = [ + .posixPermissions: NSNumber(value: Int16(0o700)) + ] + try fileManager.createDirectory( + at: cacheDirectoryURL, + withIntermediateDirectories: true, + attributes: attributes + ) + try? fileManager.setAttributes(attributes, ofItemAtPath: cacheDirectoryURL.path) + } + + private func snapshotURL(for connection: ConnectionProfile) -> URL { + let digest = SHA256.hash(data: Data(connection.commandCenterClientFingerprint.utf8)) + let hexDigest = digest.map { String(format: "%02x", $0) }.joined() + return cacheDirectoryURL.appendingPathComponent("\(hexDigest).json") + } +} + +extension CaelCommandCenterSectionsSnapshot { + var hasAnyPayload: Bool { + actionGates?.data != nil || + agentRuns?.data != nil || + automations?.data != nil || + brain?.data != nil || + homebaseRecords?.data != nil || + memoryArtifacts?.data != nil || + usageLimits?.data != nil || + vaultRefs?.data != nil + } + + var sanitizedForLocalCache: CaelCommandCenterSectionsSnapshot { + CaelCommandCenterSectionsSnapshot( + actionGates: actionGates, + agentRuns: agentRuns, + automations: automations, + brain: brain, + homebaseRecords: homebaseRecords, + memoryArtifacts: memoryArtifacts, + usageLimits: usageLimits, + vaultRefs: vaultRefs?.strippingSecretValues + ) + } +} + +private extension CaelCommandCenterSectionEnvelope where Payload == CaelCommandCenterVaultRefsSection { + var strippingSecretValues: CaelCommandCenterSectionEnvelope { + guard let data else { return self } + let sanitizedRefs = data.refs.map { ref in + CaelCommandCenterVaultRef( + id: ref.id, + displayName: ref.displayName, + scope: ref.scope, + exists: ref.exists, + lastVerifiedAt: ref.lastVerifiedAt, + rotationDueAt: ref.rotationDueAt, + linkedSystems: ref.linkedSystems, + vaultHref: ref.vaultHref, + secretValue: nil + ) + } + let strippedSecretCount = data.refs.filter { $0.secretValue != nil }.count + let sanitizedWarnings: [String] + if strippedSecretCount > 0 { + sanitizedWarnings = warnings + ["Secret values were stripped before local cache storage."] + } else { + sanitizedWarnings = warnings + } + return CaelCommandCenterSectionEnvelope( + ok: ok, + generatedAt: generatedAt, + source: source, + scope: scope, + data: CaelCommandCenterVaultRefsSection( + warningCount: data.warningCount, + refs: sanitizedRefs, + policy: data.policy + ), + warnings: sanitizedWarnings, + errors: errors + ) + } +} diff --git a/Sources/HermesDesktop/Services/RemoteHermesService.swift b/Sources/HermesDesktop/Services/RemoteHermesService.swift index 296133f..e25bc5c 100644 --- a/Sources/HermesDesktop/Services/RemoteHermesService.swift +++ b/Sources/HermesDesktop/Services/RemoteHermesService.swift @@ -112,6 +112,13 @@ final class RemoteHermesService: @unchecked Sendable { kanban_database_path = default_hermes_home / "kanban.db" profiles_dir = default_hermes_home / "profiles" + def profile_display_name(name): + if name == "default": + return "Cael" + if name == "cael": + return "Cael Legacy Profile" + return name.replace("_", " ").replace("-", " ").title() + active_profile_name = payload.get("profile_name") if hermes_home == default_hermes_home: active_profile_name = "default" @@ -120,6 +127,7 @@ final class RemoteHermesService: @unchecked Sendable { active_profile = { "name": active_profile_name, + "display_name": profile_display_name(active_profile_name), "path": tilde(hermes_home, home), "is_default": hermes_home == default_hermes_home, "exists": hermes_home.exists(), @@ -130,6 +138,7 @@ final class RemoteHermesService: @unchecked Sendable { else: available_profiles = [{ "name": "default", + "display_name": profile_display_name("default"), "path": tilde(default_hermes_home, home), "is_default": True, "exists": default_hermes_home.exists(), @@ -142,6 +151,7 @@ final class RemoteHermesService: @unchecked Sendable { ): available_profiles.append({ "name": item.name, + "display_name": profile_display_name(item.name), "path": tilde(item, home), "is_default": False, "exists": True, diff --git a/Sources/HermesDesktop/Services/Terminal/TerminalSession.swift b/Sources/HermesDesktop/Services/Terminal/TerminalSession.swift index 1db99ed..75bc3a1 100644 --- a/Sources/HermesDesktop/Services/Terminal/TerminalSession.swift +++ b/Sources/HermesDesktop/Services/Terminal/TerminalSession.swift @@ -2,12 +2,24 @@ import Foundation @MainActor final class TerminalSession: ObservableObject, @unchecked Sendable { + enum Backend: Equatable { + case nativeSSH + case workspacePTY(sessionId: String? = nil) + + var attachedWorkspaceSessionId: String? { + guard case let .workspacePTY(sessionId) = self else { return nil } + return sessionId + } + } + let connection: ConnectionProfile let sshArguments: [String] let startupInput: String? let workflowLaunchDiagnosticsContext: WorkflowLaunchDiagnosticsContext? + let backend: Backend private let workflowLaunchDiagnostics: WorkflowLaunchDiagnostics private let viewHost = TerminalViewHost() + private let workspaceViewHost = WorkspaceTerminalViewHost() @Published var terminalTitle: String @Published var currentDirectory: String? @@ -21,18 +33,22 @@ final class TerminalSession: ObservableObject, @unchecked Sendable { sshTransport: SSHTransport, startupCommandLine: String? = nil, startupInput: String? = nil, + backend: Backend = .nativeSSH, workflowLaunchDiagnostics: WorkflowLaunchDiagnostics, workflowLaunchDiagnosticsContext: WorkflowLaunchDiagnosticsContext? = nil ) { self.connection = connection self.startupInput = startupInput + self.backend = backend self.workflowLaunchDiagnostics = workflowLaunchDiagnostics self.workflowLaunchDiagnosticsContext = workflowLaunchDiagnosticsContext self.sshArguments = sshTransport.shellArguments( for: connection, startupCommandLine: startupCommandLine ) - self.terminalTitle = "\(connection.label) · \(connection.resolvedHermesProfileName)" + self.terminalTitle = backend.isWorkspacePTY + ? "Shared PTY · \(connection.resolvedHermesProfileName)" + : "\(connection.label) · \(connection.resolvedHermesProfileName)" viewHost.setEventHandlers( onProcessStart: { [weak self] in self?.markStarted() @@ -47,6 +63,20 @@ final class TerminalSession: ObservableObject, @unchecked Sendable { self?.markExited(exitCode) } ) + workspaceViewHost.setEventHandlers( + onProcessStart: { [weak self] in + self?.markStarted() + }, + onTitleChange: { [weak self] title in + self?.updateTitle(title) + }, + onDirectoryChange: { [weak self] directory in + self?.currentDirectory = directory + }, + onProcessExit: { [weak self] exitCode in + self?.markExited(exitCode) + } + ) } deinit { @@ -84,27 +114,69 @@ final class TerminalSession: ObservableObject, @unchecked Sendable { } func mount(in container: TerminalMountContainerView, appearance: TerminalThemeAppearance, isActive: Bool) { - viewHost.mount( - in: container, - request: TerminalLaunchRequest( - sshArguments: sshArguments, - launchToken: launchToken, - initialInput: startupInput, - workflowLaunchDiagnostics: workflowLaunchDiagnostics, - workflowLaunchDiagnosticsContext: workflowLaunchDiagnosticsContext - ), - appearance: appearance, - isActive: isActive - ) + switch backend { + case .nativeSSH: + viewHost.mount( + in: container, + request: TerminalLaunchRequest( + sshArguments: sshArguments, + launchToken: launchToken, + initialInput: startupInput, + workflowLaunchDiagnostics: workflowLaunchDiagnostics, + workflowLaunchDiagnosticsContext: workflowLaunchDiagnosticsContext + ), + appearance: appearance, + isActive: isActive + ) + case .workspacePTY: + workspaceViewHost.mount( + in: container, + request: WorkspaceTerminalLaunchRequest( + baseURL: connection.resolvedCaelWorkspaceBaseURL, + launchToken: launchToken, + attachedSessionId: backend.attachedWorkspaceSessionId, + label: "Desktop · \(connection.resolvedHermesProfileName)" + ), + appearance: appearance, + isActive: isActive + ) + } } func unmount(from container: TerminalMountContainerView) { - viewHost.unmount(from: container) + switch backend { + case .nativeSSH: + viewHost.unmount(from: container) + case .workspacePTY: + workspaceViewHost.unmount(from: container) + } } func stop() { viewHost.terminate() + workspaceViewHost.terminate() isRunning = false currentDirectory = nil } + + var backendLabel: String { + switch backend { + case .nativeSSH: + return "Native SSH" + case .workspacePTY(let sessionId): + return sessionId == nil ? "Shared PTY" : "Attached PTY" + } + } + + var isWorkspacePTY: Bool { + backend.isWorkspacePTY + } +} + + +private extension TerminalSession.Backend { + var isWorkspacePTY: Bool { + if case .workspacePTY = self { return true } + return false + } } diff --git a/Sources/HermesDesktop/Services/Terminal/TerminalViewHost.swift b/Sources/HermesDesktop/Services/Terminal/TerminalViewHost.swift index 50491c9..6be362d 100644 --- a/Sources/HermesDesktop/Services/Terminal/TerminalViewHost.swift +++ b/Sources/HermesDesktop/Services/Terminal/TerminalViewHost.swift @@ -342,7 +342,7 @@ final class TerminalHostView: NSView { send(bytes: TerminalInputSequence.bracketedPasteSubmission(for: text)) } - private static func makeTerminalColor(from themeColor: TerminalThemeColor) -> SwiftTerm.Color { + static func makeTerminalColor(from themeColor: TerminalThemeColor) -> SwiftTerm.Color { let color = themeColor.nsColor.usingColorSpace(.deviceRGB) ?? .black return SwiftTerm.Color( red: UInt16(color.redComponent * 65535), diff --git a/Sources/HermesDesktop/Services/Terminal/TerminalWorkspaceStore.swift b/Sources/HermesDesktop/Services/Terminal/TerminalWorkspaceStore.swift index 5735638..8e4b34b 100644 --- a/Sources/HermesDesktop/Services/Terminal/TerminalWorkspaceStore.swift +++ b/Sources/HermesDesktop/Services/Terminal/TerminalWorkspaceStore.swift @@ -57,6 +57,7 @@ final class TerminalWorkspaceStore: ObservableObject { for connection: ConnectionProfile, startupCommandLine: String? = nil, startupInput: String? = nil, + backend: TerminalSession.Backend = .nativeSSH, workflowLaunchDiagnosticsContext: WorkflowLaunchDiagnosticsContext? = nil ) -> TerminalTabModel { let session = TerminalSession( @@ -64,6 +65,7 @@ final class TerminalWorkspaceStore: ObservableObject { sshTransport: sshTransport, startupCommandLine: startupCommandLine, startupInput: startupInput, + backend: backend, workflowLaunchDiagnostics: workflowLaunchDiagnostics, workflowLaunchDiagnosticsContext: workflowLaunchDiagnosticsContext ) @@ -79,6 +81,13 @@ final class TerminalWorkspaceStore: ObservableObject { return tab } + @discardableResult + func addWorkspaceTerminalTab(for connection: ConnectionProfile, sessionId: String? = nil) -> TerminalTabModel { + let trimmedSessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedSessionId = trimmedSessionId?.isEmpty == false ? trimmedSessionId : nil + return addTab(for: connection, backend: .workspacePTY(sessionId: resolvedSessionId)) + } + func closeTab(_ tab: TerminalTabModel) { if selectedTabID == tab.id { selectTab(tabs.last(where: { $0.id != tab.id })?.id) diff --git a/Sources/HermesDesktop/Services/Terminal/WorkspaceTerminalViewHost.swift b/Sources/HermesDesktop/Services/Terminal/WorkspaceTerminalViewHost.swift new file mode 100644 index 0000000..90d559d --- /dev/null +++ b/Sources/HermesDesktop/Services/Terminal/WorkspaceTerminalViewHost.swift @@ -0,0 +1,345 @@ +import AppKit +import Foundation +@preconcurrency import SwiftTerm + +@MainActor +final class WorkspaceTerminalViewHost: NSObject, TerminalViewDelegate { + private let hostView = WorkspaceTerminalHostView() + private var appliedAppearance: TerminalThemeAppearance? + private var startedLaunchToken: UUID? + private var scheduledLaunchToken: UUID? + private var streamTask: Task? + private var sessionId: String? + private var baseURL: URL? + private var shouldCloseServerSessionOnTerminate = true + private var onProcessStart: (() -> Void)? + private var onTitleChange: ((String) -> Void)? + private var onDirectoryChange: ((String?) -> Void)? + private var onProcessExit: ((Int32?) -> Void)? + + override init() { + super.init() + hostView.terminalView.terminalDelegate = self + } + + func setEventHandlers( + onProcessStart: @escaping () -> Void, + onTitleChange: @escaping (String) -> Void, + onDirectoryChange: @escaping (String?) -> Void, + onProcessExit: @escaping (Int32?) -> Void + ) { + self.onProcessStart = onProcessStart + self.onTitleChange = onTitleChange + self.onDirectoryChange = onDirectoryChange + self.onProcessExit = onProcessExit + } + + func mount( + in container: TerminalMountContainerView, + request: WorkspaceTerminalLaunchRequest, + appearance: TerminalThemeAppearance, + isActive: Bool + ) { + container.mount(hostView) + applyAppearance(appearance) + setActive(isActive) + scheduleStartIfNeeded(for: request) + } + + func unmount(from container: TerminalMountContainerView) { + container.unmountHostedView() + } + + nonisolated func terminate() { + Task { @MainActor [weak self] in + self?.terminateOnMainThread() + } + } + + nonisolated func send(source _: TerminalView, data: ArraySlice) { + let text = String(decoding: data, as: UTF8.self) + Task { [weak self] in + await self?.sendInput(text) + } + } + + nonisolated func sizeChanged(source _: TerminalView, newCols: Int, newRows: Int) { + Task { [weak self] in + await self?.resize(cols: newCols, rows: newRows) + } + } + + nonisolated func setTerminalTitle(source _: TerminalView, title: String) { + Task { @MainActor [weak self] in + self?.onTitleChange?(title) + } + } + + nonisolated func hostCurrentDirectoryUpdate(source _: TerminalView, directory: String?) { + Task { @MainActor [weak self] in + self?.onDirectoryChange?(directory) + } + } + + nonisolated func scrolled(source _: TerminalView, position _: Double) {} + + nonisolated func requestOpenLink(source _: TerminalView, link: String, params _: [String: String]) { + guard let url = URL(string: link) else { return } + NSWorkspace.shared.open(url) + } + + nonisolated func bell(source _: TerminalView) { + NSSound.beep() + } + + nonisolated func clipboardCopy(source _: TerminalView, content: Data) { + guard let text = String(data: content, encoding: .utf8) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + nonisolated func iTermContent(source _: TerminalView, content _: ArraySlice) {} + + nonisolated func rangeChanged(source _: TerminalView, startY _: Int, endY _: Int) {} + + private func scheduleStartIfNeeded(for request: WorkspaceTerminalLaunchRequest) { + let launchToken = request.launchToken + guard startedLaunchToken != launchToken else { return } + guard scheduledLaunchToken != launchToken else { return } + scheduledLaunchToken = launchToken + + Task { @MainActor [weak self] in + self?.startIfNeeded(for: request) + } + } + + private func startIfNeeded(for request: WorkspaceTerminalLaunchRequest) { + scheduledLaunchToken = nil + guard startedLaunchToken != request.launchToken else { return } + guard let url = URL(string: request.baseURL) else { + feedStatusLine("Invalid Workspace terminal URL: \(request.baseURL)") + onProcessExit?(1) + return + } + + startedLaunchToken = request.launchToken + baseURL = url + sessionId = request.attachedSessionId + shouldCloseServerSessionOnTerminate = request.attachedSessionId == nil + streamTask?.cancel() + streamTask = Task { [weak self] in + await self?.runStream(baseURL: url, launchToken: request.launchToken, requestedLabel: request.label) + } + onProcessStart?() + } + + private func runStream(baseURL: URL, launchToken: UUID, requestedLabel: String?) async { + do { + var body: [String: Any] = [ + "cols": max(20, hostView.terminalView.terminal.cols), + "rows": max(5, hostView.terminalView.terminal.rows) + ] + if let sessionId { + body["sessionId"] = sessionId + } else if let label = requestedLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { + body["label"] = String(label.prefix(80)) + } + + var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "/api/terminal-stream")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (lines, response) = try await URLSession.shared.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + feedStatusLine("Workspace terminal stream failed.") + onProcessExit?(1) + return + } + + var eventName = "message" + var eventDataLines: [String] = [] + for try await line in lines.lines { + if Task.isCancelled || startedLaunchToken != launchToken { return } + if line.isEmpty { + handleSSEEvent(name: eventName, data: eventDataLines.joined(separator: "\n")) + eventName = "message" + eventDataLines.removeAll(keepingCapacity: true) + continue + } + if line.hasPrefix("event: ") { + eventName = String(line.dropFirst(7)) + } else if line.hasPrefix("data: ") { + eventDataLines.append(String(line.dropFirst(6))) + } + } + } catch is CancellationError { + return + } catch { + feedStatusLine("Workspace terminal stream error: \(error.localizedDescription)") + onProcessExit?(1) + } + } + + private func handleSSEEvent(name: String, data: String) { + guard !data.isEmpty else { return } + switch name { + case "session": + if let payload = decode(WorkspaceTerminalSessionPayload.self, from: data) { + sessionId = payload.sessionId + shouldCloseServerSessionOnTerminate = payload.reattach != true && shouldCloseServerSessionOnTerminate + let prefix = payload.reattach == true ? "Attached PTY" : "Shared PTY" + let label = payload.label?.trimmingCharacters(in: .whitespacesAndNewlines) + let titleSuffix = label?.isEmpty == false ? (label ?? "") : String(payload.sessionId.prefix(8)) + onTitleChange?("\(prefix) · \(titleSuffix)") + } + case "data": + if let text = decode(String.self, from: data) { + hostView.terminalView.feed(byteArray: Array(text.utf8)[...]) + } else if let payload = decode(WorkspaceTerminalDataPayload.self, from: data) { + hostView.terminalView.feed(byteArray: Array(payload.data.utf8)[...]) + } + case "exit": + let code = decode(WorkspaceTerminalExitPayload.self, from: data)?.exitCode + onProcessExit?(code) + case "close": + onProcessExit?(0) + default: + break + } + } + + private func sendInput(_ text: String) async { + guard !text.isEmpty, let sessionId, let baseURL else { return } + try? await post(baseURL: baseURL, path: "/api/terminal-input", body: [ + "sessionId": sessionId, + "data": text + ]) + } + + private func resize(cols: Int, rows: Int) async { + guard let sessionId, let baseURL else { return } + try? await post(baseURL: baseURL, path: "/api/terminal-resize", body: [ + "sessionId": sessionId, + "cols": max(20, cols), + "rows": max(5, rows) + ]) + } + + private func post(baseURL: URL, path: String, body: [String: Any]) async throws { + var request = URLRequest(url: try endpoint(baseURL: baseURL, path: path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + _ = try await URLSession.shared.data(for: request) + } + + private func closeServerSessionIfNeeded() { + guard shouldCloseServerSessionOnTerminate, let sessionId, let baseURL else { return } + let closeSessionId = sessionId + Task { + try? await post(baseURL: baseURL, path: "/api/terminal-close", body: ["sessionId": closeSessionId]) + } + } + + private func terminateOnMainThread() { + scheduledLaunchToken = nil + startedLaunchToken = nil + streamTask?.cancel() + streamTask = nil + closeServerSessionIfNeeded() + sessionId = nil + } + + private func applyAppearance(_ appearance: TerminalThemeAppearance) { + guard appliedAppearance != appearance else { return } + appliedAppearance = appearance + hostView.apply(appearance: appearance) + } + + private func setActive(_ isActive: Bool) { + hostView.isHidden = !isActive + if !isActive { + hostView.window?.makeFirstResponder(nil) + } else { + hostView.window?.makeFirstResponder(hostView.terminalView) + } + } + + private func feedStatusLine(_ text: String) { + hostView.terminalView.feed(text: "\r\n\(text)\r\n") + } + + private func endpoint(baseURL: URL, path: String) throws -> URL { + guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { + throw URLError(.badURL) + } + return url + } + + private func decode(_ type: T.Type, from text: String) -> T? { + guard let data = text.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(type, from: data) + } +} + +struct WorkspaceTerminalLaunchRequest { + let baseURL: String + let launchToken: UUID + let attachedSessionId: String? + let label: String? +} + +private struct WorkspaceTerminalSessionPayload: Decodable { + let sessionId: String + let reattach: Bool? + let label: String? +} + +private struct WorkspaceTerminalDataPayload: Decodable { + let data: String +} + +private struct WorkspaceTerminalExitPayload: Decodable { + let exitCode: Int32? +} + +final class WorkspaceTerminalHostView: NSView { + let terminalView = TerminalView(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + + terminalView.translatesAutoresizingMaskIntoConstraints = false + addSubview(terminalView) + + NSLayoutConstraint.activate([ + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(appearance: TerminalThemeAppearance) { + let backgroundColor = appearance.backgroundColor.nsColor + let foregroundColor = appearance.foregroundColor.nsColor + + layer?.backgroundColor = backgroundColor.cgColor + terminalView.nativeBackgroundColor = backgroundColor + terminalView.nativeForegroundColor = foregroundColor + terminalView.selectedTextBackgroundColor = foregroundColor.withAlphaComponent(0.28) + terminalView.caretColor = foregroundColor + terminalView.caretTextColor = backgroundColor + terminalView.installColors(appearance.ansiPalette.map(TerminalHostView.makeTerminalColor(from:))) + } +} diff --git a/Sources/HermesDesktop/Views/CommandCenter/CommandCenterMirrorView.swift b/Sources/HermesDesktop/Views/CommandCenter/CommandCenterMirrorView.swift new file mode 100644 index 0000000..8044928 --- /dev/null +++ b/Sources/HermesDesktop/Views/CommandCenter/CommandCenterMirrorView.swift @@ -0,0 +1,3204 @@ +import SwiftUI + +struct CommandCenterMirrorView: View { + @EnvironmentObject private var appState: AppState + + let section: AppSection + + var body: some View { + HermesPageContainer(width: .analytics) { + VStack(alignment: .leading, spacing: 24) { + HermesPageHeader( + title: section.title, + subtitle: subtitle, + accessory: { + HermesRefreshButton(isRefreshing: appState.isRefreshingCaelWorkspace) { + Task { await appState.refreshCaelWorkspace() } + } + } + ) + + content + } + } + .task(id: appState.activeConnectionID) { + await appState.loadCaelWorkspace() + } + } + + private var subtitle: String { + switch section { + case .mail: + return "Native command-center view for Google Workspace mail readiness, drafts, and approval gates." + case .contacts: + return "Native command-center view for people records, Google contacts readiness, and migration-safe homebase refs." + case .calendar: + return "Native command-center view for calendar readiness, agenda drafting policy, and approval gates." + case .missionControl: + return "Native command-center view for active runs, receipts, and gated promotions." + case .operations: + return "Native command-center view for automation health, n8n lanes, and runtime posture." + case .swarm: + return "Native command-center view for agent runs and team execution receipts." + case .memory: + return "Native command-center view for brain sources and durable memory artifacts." + case .integrations: + return "Native command-center view for provider readiness and Vault reference posture." + case .mcp: + return "Native command-center view for MCP/brain source availability and safe client boundaries." + case .profiles: + return "Native command-center view for the active default profile and available Hermes agent identities." + default: + return "Native Command Center mirror section." + } + } + + @ViewBuilder + private var content: some View { + if appState.isLoadingCaelWorkspace, + appState.caelCommandCenterSummary == nil, + appState.caelCommandCenterSections == nil { + HermesSurfacePanel { + HermesLoadingState(label: "Loading command center section...", minHeight: 320) + } + } else if let error = appState.caelWorkspaceError, + appState.caelCommandCenterSummary == nil, + appState.caelCommandCenterSections == nil { + HermesSurfacePanel { + ContentUnavailableView( + "Unable to load command center", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .frame(maxWidth: .infinity, minHeight: 320) + } + } else { + if let cacheNotice = appState.caelCommandCenterCacheNotice { + HermesInsetSurface { + MirrorRow(title: "Last-known snapshot", detail: cacheNotice, badge: "Cached", tint: .orange) + } + } + + switch section { + case .mail, .contacts, .calendar: + workspaceDataSection + case .missionControl: + runsAndGatesSection + case .swarm: + swarmSection + case .operations: + operationsSection + case .memory: + memorySection + case .integrations: + integrationsSection + case .mcp: + mcpSection + case .profiles: + profilesSection + default: + ContentUnavailableView("Unsupported section", systemImage: "rectangle.slash") + } + } + } + + private var workspaceDataSection: some View { + VStack(alignment: .leading, spacing: 16) { + HermesSurfacePanel(title: "Provider Readiness", subtitle: "Mail, contacts, and calendars use the shared integrations contract before any mutation is allowed.") { + LazyVGrid(columns: adaptiveColumns, spacing: 12) { + ForEach(relevantIntegrations) { integration in + MirrorListCard(title: integration.label, emptyText: "No integration detail reported.") { + MirrorRow( + title: integration.status.replacingOccurrences(of: "-", with: " "), + detail: integration.detail, + badge: integration.status, + tint: tint(forStatus: integration.status) + ) + MirrorRow(title: "Safe mode", detail: integration.safeMode, tint: .secondary) + } + } + } + } + + if section == .contacts { + homebaseRecordsPanel + } + + actionGatesPanel + } + } + + private var runsAndGatesSection: some View { + VStack(alignment: .leading, spacing: 16) { + agentRunsPanel + actionGatesPanel + } + } + + private var swarmSection: some View { + VStack(alignment: .leading, spacing: 16) { + NativeSwarmPanel() + NativeConductorPanel() + agentRunsPanel + actionGatesPanel + } + } + + private var operationsSection: some View { + VStack(alignment: .leading, spacing: 16) { + NativeOperationsAgentsPanel() + + HermesSurfacePanel(title: "Automation Lanes", subtitle: appState.caelCommandCenterSections?.automations?.data?.boundary ?? "Business and personal automation lanes remain separated.") { + let instances = appState.caelCommandCenterSections?.automations?.data?.instances ?? [] + MirrorGridOrEmpty(items: instances, emptyText: "No automation lanes reported.") { instance in + MirrorListCard(title: instance.label, emptyText: "No automation detail reported.") { + MirrorRow(title: instance.health.ok ? "Online" : "Needs attention", detail: instance.health.detail, badge: instance.scope, tint: instance.health.ok ? .green : .orange) + MirrorRow(title: "Boundary", detail: instance.boundary, tint: .blue) + MirrorRow(title: "Recent failures", detail: "\(instance.failures.count) failure families", badge: "\(instance.failures.count)", tint: instance.failures.isEmpty ? .green : .orange) + } + } + } + + systemsPanel + } + } + + private var memorySection: some View { + VStack(alignment: .leading, spacing: 16) { + NativeKnowledgeFabricPanel() + NativeMemoryKnowledgeFilesPanel() + brainSourcesPanel + memoryArtifactsPanel + } + } + + private var integrationsSection: some View { + VStack(alignment: .leading, spacing: 16) { + HermesSurfacePanel(title: "Integrations", subtitle: "Provider readiness is rendered from the same shared contract as the web app.") { + MirrorGridOrEmpty(items: appState.caelCommandCenterSummary?.integrations ?? [], emptyText: "No integrations reported.") { integration in + MirrorListCard(title: integration.label, emptyText: "No integration detail reported.") { + MirrorRow(title: integration.status.replacingOccurrences(of: "-", with: " "), detail: integration.detail, badge: integration.status, tint: tint(forStatus: integration.status)) + MirrorRow(title: "Safe mode", detail: integration.safeMode, tint: .secondary) + } + } + } + vaultRefsPanel + } + } + + private var mcpSection: some View { + VStack(alignment: .leading, spacing: 16) { + NativeMCPPanel() + brainSourcesPanel + vaultRefsPanel + } + } + + private var profilesSection: some View { + HermesSurfacePanel(title: "Agent Profiles", subtitle: "The base profile remains default; the agent display name is what changes per identity.") { + let profiles = availableProfiles + MirrorGridOrEmpty(items: profiles, emptyText: "No profiles reported by the active host.") { profile in + MirrorListCard(title: profile.displayTitle, emptyText: "No profile detail reported.") { + MirrorRow(title: profile.isDefault ? "Default base profile" : "Agent profile", detail: profile.path, badge: profile.exists ? "Available" : "Missing", tint: profile.exists ? .green : .orange) + MirrorRow(title: "Profile name", detail: profile.name, tint: .blue) + } + } + } + } + + private var actionGatesPanel: some View { + HermesSurfacePanel(title: "Action Gates", subtitle: "Mutating actions stay approval-gated and dry-run capable where supported.") { + let gates = appState.caelCommandCenterSections?.actionGates?.data?.actions ?? [] + MirrorGridOrEmpty(items: gates, emptyText: "No approval gates surfaced.") { gate in + MirrorListCard(title: gate.label, emptyText: "No gate detail reported.") { + MirrorRow(title: gate.status, detail: gate.detail, badge: gate.riskLevel.replacingOccurrences(of: "_", with: " "), tint: gate.approvalRequired ? .orange : .green) + MirrorRow(title: "Owner", detail: gate.ownerSystem, tint: .blue) + MirrorRow(title: "Rollback", detail: gate.rollback, tint: .secondary) + } + } + } + } + + private var agentRunsPanel: some View { + HermesSurfacePanel(title: "Runs + Receipts", subtitle: "Runs and promotion receipts come from durable command-center references.") { + let runs = appState.caelCommandCenterSections?.agentRuns?.data?.runs ?? [] + MirrorGridOrEmpty(items: runs, emptyText: "No agent runs reported.") { run in + MirrorListCard(title: run.title, emptyText: "No run detail reported.") { + MirrorRow(title: run.status, detail: run.verification, badge: run.source, tint: tint(forStatus: run.status)) + MirrorRow(title: "Updated", detail: run.updatedAt, tint: .secondary) + if let path = run.path { + MirrorRow(title: "Receipt path", detail: path, tint: .cyan) + } + } + } + } + } + + private var brainSourcesPanel: some View { + HermesSurfacePanel(title: "Brain Sources", subtitle: "Sources are shown as references; secret stores are never exposed in the client.") { + let sources = appState.caelCommandCenterSections?.brain?.data?.sources ?? appState.caelCommandCenterSummary?.brain?.sources ?? [] + MirrorGridOrEmpty(items: sources, emptyText: "No brain sources reported.") { source in + MirrorListCard(title: source.label, emptyText: "No source detail reported.") { + MirrorRow(title: source.status, detail: source.category, badge: source.writable ? "Writable" : "Read only", tint: source.status == "available" ? .green : .orange) + } + } + } + } + + private var memoryArtifactsPanel: some View { + HermesSurfacePanel(title: "Memory Artifacts", subtitle: "Durable handoffs and receipts are surfaced by path and excerpt only.") { + let artifacts = appState.caelCommandCenterSections?.memoryArtifacts?.data?.artifacts ?? [] + MirrorGridOrEmpty(items: artifacts, emptyText: "No memory artifacts surfaced.") { artifact in + MirrorListCard(title: artifact.title, emptyText: "No artifact detail reported.") { + MirrorRow(title: artifact.scope, detail: artifact.excerpt.isEmpty ? artifact.path : artifact.excerpt, badge: artifact.sensitivity, tint: artifact.sensitivity == "secret_ref" ? .orange : .mint) + MirrorRow(title: "Path", detail: artifact.path, tint: .secondary) + } + } + } + } + + private var vaultRefsPanel: some View { + HermesSurfacePanel(title: "Vault References", subtitle: "Reference-only credential posture. Secret values are filtered before reaching the desktop client.") { + let refs = appState.caelCommandCenterSections?.vaultRefs?.data?.refs ?? [] + MirrorGridOrEmpty(items: refs, emptyText: "No vault refs surfaced.") { ref in + MirrorListCard(title: ref.displayName, emptyText: "No vault detail reported.") { + MirrorRow(title: ref.exists ? "Configured" : "Missing", detail: ref.scope, badge: ref.exists ? "Ref" : "Setup", tint: ref.exists ? .green : .orange) + MirrorRow(title: "Linked systems", detail: ref.linkedSystems.joined(separator: ", ").nilIfBlank ?? "No linked systems reported.", tint: .blue) + } + } + } + } + + private var homebaseRecordsPanel: some View { + HermesSurfacePanel(title: "Homebase Records", subtitle: "Legacy Twenty and migrated records remain read-first until explicit approval gates exist.") { + let records = appState.caelCommandCenterSections?.homebaseRecords?.data?.records ?? [] + MirrorGridOrEmpty(items: records, emptyText: "No homebase records reported.") { record in + MirrorListCard(title: record.label, emptyText: "No record detail reported.") { + MirrorRow(title: record.kind, detail: record.updatedAt ?? "No update timestamp", tint: .secondary) + } + } + } + } + + private var systemsPanel: some View { + HermesSurfacePanel(title: "Runtime Systems", subtitle: "Shared command-center posture for Desktop and Web.") { + MirrorGridOrEmpty(items: appState.caelCommandCenterSummary?.systems ?? [], emptyText: "No system checks reported.") { system in + MirrorListCard(title: system.label, emptyText: "No system detail reported.") { + MirrorRow(title: system.ok ? "Online" : "Needs attention", detail: system.detail, badge: system.lane, tint: system.ok ? .green : .orange) + MirrorRow(title: "Owner", detail: system.owner, tint: .blue) + } + } + } + } + + private var relevantIntegrations: [CaelCommandCenterIntegration] { + let integrations = appState.caelCommandCenterSummary?.integrations ?? [] + switch section { + case .mail, .contacts, .calendar: + return integrations.filter { $0.id.contains("google") || $0.label.localizedCaseInsensitiveContains("google") } + default: + return integrations + } + } + + private var availableProfiles: [RemoteHermesProfile] { + if let overview = appState.overview, !overview.availableProfiles.isEmpty { + return overview.availableProfiles + } + + guard let connection = appState.activeConnection else { return [] } + return [ + RemoteHermesProfile( + name: connection.resolvedHermesProfileName, + path: connection.remoteHermesHomePath, + isDefault: connection.usesDefaultHermesProfile, + exists: true, + displayName: connection.agentDisplayName + ) + ] + } + + private var adaptiveColumns: [GridItem] { + [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)] + } + + private func tint(forStatus status: String) -> Color { + switch status { + case "ready", "available", "complete", "online", "ok": return .green + case "active", "running": return .cyan + case "setup-needed", "warning", "error", "needs_attention": return .orange + default: return .blue + } + } +} + +private struct MirrorGridOrEmpty: View { + let items: [Item] + let emptyText: String + let content: (Item) -> Content + + var body: some View { + if items.isEmpty { + Text(emptyText) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(items) { item in + content(item) + } + } + } + } +} + + +private struct NativeOperationsAgentsPanel: View { + @EnvironmentObject private var appState: AppState + + @State private var profiles: [CaelProfileSummary] = [] + @State private var crew: [WorkspaceCrewMember] = [] + @State private var jobs: [CronJob] = [] + @State private var activeProfileName = "default" + @State private var isLoading = false + @State private var isCreating = false + @State private var isStartingRuntime = false + @State private var isSavingProfile = false + @State private var isLoadingEditDetail = false + @State private var operationProfileName: String? + @State private var operationJobID: String? + @State private var operationSessionKey: String? + @State private var errorMessage: String? + @State private var runtimeMessage: String? + @State private var newAgentName = "" + @State private var newAgentModel = "" + @State private var editingProfile: CaelProfileSummary? + @State private var editModel = "" + @State private var editProvider = "" + @State private var editDescription = "" + @State private var editSystemPrompt = "" + @State private var operationChatDrafts: [String: String] = [:] + @State private var operationChatMessages: [String: [SessionMessage]] = [:] + @State private var operationChatNotices: [String: String] = [:] + @State private var profilePendingDelete: CaelProfileSummary? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HermesSurfacePanel( + title: "Operations Agents", + subtitle: "Native controls for the same profile-backed agents used by the web Operations screen." + ) { + VStack(alignment: .leading, spacing: 16) { + controlsRow + createRow + crewSummaryRow + + if let errorMessage { + MirrorRow(title: "Error", detail: errorMessage, badge: "Attention", tint: .orange) + } + + if let runtimeMessage { + MirrorRow(title: "Runtime", detail: runtimeMessage, badge: "Start", tint: .green) + } + + MirrorGridOrEmpty(items: profiles, emptyText: "No profile-backed operations agents reported.") { profile in + agentCard(profile) + } + } + } + + HermesSurfacePanel(title: "Recent Agent Runs", subtitle: "Durable run receipts from the shared command-center ledger.") { + let runs = Array((appState.caelCommandCenterSections?.agentRuns?.data?.runs ?? []).prefix(4)) + MirrorGridOrEmpty(items: runs, emptyText: "No recent agent runs reported.") { run in + MirrorListCard(title: run.title, emptyText: "No run detail reported.") { + MirrorRow(title: run.status, detail: run.verification, badge: run.source, tint: tint(forStatus: run.status)) + MirrorRow(title: "Updated", detail: run.updatedAt, tint: .secondary) + } + } + } + } + .task(id: appState.activeConnectionID) { + await loadAll() + } + .alert( + "Delete operations agent?", + isPresented: Binding( + get: { profilePendingDelete != nil }, + set: { if !$0 { profilePendingDelete = nil } } + ) + ) { + Button("Cancel", role: .cancel) { + profilePendingDelete = nil + } + Button("Delete", role: .destructive) { + guard let profile = profilePendingDelete else { return } + profilePendingDelete = nil + Task { await deleteProfile(profile) } + } + } message: { + Text("This removes the server-side Hermes profile for this operations agent.") + } + .sheet(item: $editingProfile) { profile in + editSheet(profile) + } + } + + private var controlsRow: some View { + HStack(spacing: 10) { + Button { + Task { await loadAll() } + } label: { + Label("Refresh Agents", systemImage: "arrow.clockwise") + } + .disabled(isLoading || appState.activeConnection == nil) + + Button { + Task { await startRuntime() } + } label: { + Label(isStartingRuntime ? "Starting Runtime" : "Start Runtime", systemImage: "play.circle") + } + .buttonStyle(.borderedProminent) + .disabled(isStartingRuntime || appState.activeConnection == nil) + + if isLoading { + ProgressView() + .controlSize(.small) + } + + Spacer(minLength: 10) + + Text("Active: \(activeProfileName)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + private var createRow: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Create profile-backed agent") + .font(.subheadline.weight(.semibold)) + HStack(spacing: 10) { + TextField("agent-name", text: $newAgentName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + TextField("model", text: $newAgentModel) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 260) + Button { + Task { await createAgent() } + } label: { + Label(isCreating ? "Creating" : "Create", systemImage: "plus") + } + .buttonStyle(.bordered) + .disabled(isCreating || appState.activeConnection == nil || !isValidProfileName(newAgentName)) + } + } + } + + private var crewSummaryRow: some View { + HStack(spacing: 10) { + OperationMiniStat(label: "profiles", value: "\(profiles.count)") + OperationMiniStat(label: "crew", value: "\(crew.count)") + OperationMiniStat(label: "running", value: "\(crew.filter(\.processAlive).count)") + OperationMiniStat(label: "sessions", value: "\(crew.reduce(0) { $0 + $1.sessionCount })") + OperationMiniStat(label: "jobs", value: "\(crew.reduce(0) { $0 + $1.cronJobCount })") + OperationMiniStat(label: "ops jobs", value: "\(jobs.filter { $0.name.hasPrefix("ops:") }.count)") + } + } + + private func agentCard(_ profile: CaelProfileSummary) -> some View { + let crewMember = crewMember(for: profile) + return MirrorListCard(title: profile.resolvedDisplayName, emptyText: "No operations agent detail reported.") { + MirrorRow( + title: profile.active ? "Active profile" : "Profile agent", + detail: profile.description?.nilIfBlank ?? profile.path, + badge: profile.active ? "Active" : (profile.exists ? "Ready" : "Missing"), + tint: profile.active ? .green : (profile.exists ? .blue : .orange) + ) + MirrorRow(title: "Model", detail: profile.model?.nilIfBlank ?? crewMember?.model.nilIfBlank ?? "Not configured", badge: profile.provider?.nilIfBlank ?? crewMember?.provider.nilIfBlank, tint: (profile.model?.nilIfBlank ?? crewMember?.model.nilIfBlank) == nil ? .orange : .cyan) + if let crewMember { + MirrorRow( + title: "Gateway", + detail: crewMember.processAlive ? "Process alive; \(crewMember.gatewayState)." : "No live process reported; \(crewMember.gatewayState).", + badge: crewMember.profileFound ? "Profile" : "Missing", + tint: crewMember.processAlive ? .green : .orange + ) + MirrorRow( + title: "Workload", + detail: "\(crewMember.sessionCount) sessions, \(crewMember.messageCount) messages, \(crewMember.toolCallCount) tool calls, \(crewMember.cronJobCount) jobs, \(crewMember.assignedTaskCount) tasks.", + tint: .secondary + ) + if let lastSessionAt = crewMember.lastSessionAt { + MirrorRow(title: "Last session", detail: formatTimestamp(lastSessionAt), tint: .secondary) + } + } + HStack(spacing: 10) { + OperationMiniStat(label: "skills", value: "\(profile.skillCount)") + OperationMiniStat(label: "sessions", value: "\(profile.sessionCount)") + if profile.hasEnv { + OperationMiniStat(label: "env", value: "yes") + } + } + actionButtons(profile) + operationJobsPanel(profile) + operationChatPanel(profile) + } + } + + private func actionButtons(_ profile: CaelProfileSummary) -> some View { + HStack(spacing: 8) { + Button { + Task { await beginEdit(profile) } + } label: { + Label("Edit", systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + .disabled(operationProfileName != nil) + + Button { + Task { await activateProfile(profile) } + } label: { + Label("Activate", systemImage: "checkmark.circle") + } + .buttonStyle(.bordered) + .disabled(profile.active || operationProfileName != nil) + + Button(role: .destructive) { + profilePendingDelete = profile + } label: { + Label("Delete", systemImage: "trash") + } + .buttonStyle(.bordered) + .disabled(profile.name == "default" || operationProfileName != nil) + } + .controlSize(.small) + } + + + private func operationJobsPanel(_ profile: CaelProfileSummary) -> some View { + let profileJobs = operationJobs(for: profile) + return VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Operations jobs") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Button("Refresh") { + Task { await loadOperationJobs() } + } + .buttonStyle(.plain) + .font(.caption) + } + + if profileJobs.isEmpty { + Text("No `ops:\(profile.name):` jobs reported by /api/claude-jobs.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(profileJobs.prefix(4)) { job in + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(operationJobTitle(job, profile: profile)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(job.resolvedScheduleDisplay) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + Task { await toggleOperationJob(job) } + } label: { + Image(systemName: job.isPaused || !job.enabled ? "play.circle" : "pause.circle") + } + .help(job.isPaused || !job.enabled ? "Resume job" : "Pause job") + .disabled(operationJobID == job.id) + + Button { + Task { await runOperationJob(job) } + } label: { + Image(systemName: "bolt.circle") + } + .help("Run job now") + .disabled(operationJobID == job.id) + } + .padding(8) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + } + + private func operationChatPanel(_ profile: CaelProfileSummary) -> some View { + let sessionKey = operationSessionKey(for: profile) + let messages = Array((operationChatMessages[profile.name] ?? []).suffix(4)) + let draft = Binding( + get: { operationChatDrafts[profile.name] ?? "" }, + set: { operationChatDrafts[profile.name] = $0 } + ) + + return VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Operations chat") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(sessionKey) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + Button("Refresh") { + Task { await loadOperationChat(profile) } + } + .buttonStyle(.plain) + .font(.caption) + } + + if messages.isEmpty { + Text("No recent messages in the shared agent session.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(messages) { message in + VStack(alignment: .leading, spacing: 2) { + Text(message.role.displayTitle) + .font(.caption2.weight(.semibold)) + .foregroundStyle(message.role == .user ? Color.accentColor : Color.secondary) + Text(message.content?.nilIfBlank ?? "No message content.") + .font(.caption) + .lineLimit(3) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + + if let notice = operationChatNotices[profile.name]?.nilIfBlank { + Text(notice) + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + TextField("Message \(profile.resolvedDisplayName)", text: draft) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await sendOperationChat(profile) } + } + Button { + Task { await sendOperationChat(profile) } + } label: { + Label(operationSessionKey == sessionKey ? "Sending" : "Send", systemImage: "paperplane") + } + .buttonStyle(.borderedProminent) + .disabled(operationSessionKey == sessionKey || draft.wrappedValue.nilIfBlank == nil) + } + } + .task(id: profile.name) { + await loadOperationChat(profile) + } + } + + private func editSheet(_ profile: CaelProfileSummary) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Edit Operations Agent") + .font(.title3.weight(.semibold)) + Text(profile.name) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if isLoadingEditDetail || isSavingProfile { + ProgressView() + .controlSize(.small) + } + } + + TextField("Model", text: $editModel) + .textFieldStyle(.roundedBorder) + TextField("Provider", text: $editProvider) + .textFieldStyle(.roundedBorder) + TextField("Description", text: $editDescription) + .textFieldStyle(.roundedBorder) + VStack(alignment: .leading, spacing: 6) { + Text("System prompt") + .font(.subheadline.weight(.semibold)) + TextEditor(text: $editSystemPrompt) + .font(.body) + .frame(minHeight: 180) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.18), lineWidth: 1) + } + } + + HStack { + Spacer() + Button("Cancel") { + editingProfile = nil + } + Button(isSavingProfile ? "Saving" : "Save") { + Task { await saveProfileEdits() } + } + .buttonStyle(.borderedProminent) + .disabled(isSavingProfile || appState.activeConnection == nil) + } + } + .padding(20) + .frame(width: 560) + .frame(minHeight: 520) + } + + private func loadAll() async { + await loadProfiles() + await loadCrewStatus() + await loadOperationJobs() + } + + private func loadProfiles() async { + guard let connection = appState.activeConnection else { + profiles = [] + return + } + + isLoading = true + errorMessage = nil + do { + let response = try await appState.caelWorkspaceAPIService.loadProfiles(connection: connection) + profiles = response.profiles + activeProfileName = response.activeProfile + isLoading = false + } catch { + isLoading = false + errorMessage = error.localizedDescription + } + } + + private func loadCrewStatus() async { + guard let connection = appState.activeConnection else { + crew = [] + return + } + + do { + let response = try await appState.caelWorkspaceAPIService.loadCrewStatus(connection: connection) + crew = response.crew + } catch { + errorMessage = error.localizedDescription + } + } + + + private func loadOperationJobs() async { + guard let connection = appState.activeConnection else { + jobs = [] + return + } + + do { + jobs = try await appState.caelWorkspaceAPIService.loadWorkspaceCronJobs(connection: connection) + } catch { + errorMessage = error.localizedDescription + } + } + + private func operationJobs(for profile: CaelProfileSummary) -> [CronJob] { + let prefix = "ops:\(profile.name):" + return jobs + .filter { $0.name.hasPrefix(prefix) } + .sorted { left, right in + let leftDate = left.nextRunAt ?? left.lastRunAt ?? .distantFuture + let rightDate = right.nextRunAt ?? right.lastRunAt ?? .distantFuture + return leftDate < rightDate + } + } + + private func operationJobTitle(_ job: CronJob, profile: CaelProfileSummary) -> String { + let prefix = "ops:\(profile.name):" + if job.name.hasPrefix(prefix) { + return job.name.dropFirst(prefix.count).replacingOccurrences(of: "-", with: " ").capitalized + } + return job.resolvedName + } + + private func runOperationJob(_ job: CronJob) async { + guard let connection = appState.activeConnection else { return } + operationJobID = job.id + errorMessage = nil + do { + try await appState.caelWorkspaceAPIService.triggerWorkspaceCronJob(connection: connection, jobID: job.id) + operationJobID = nil + await loadOperationJobs() + } catch { + operationJobID = nil + errorMessage = error.localizedDescription + } + } + + private func toggleOperationJob(_ job: CronJob) async { + guard let connection = appState.activeConnection else { return } + operationJobID = job.id + errorMessage = nil + do { + if job.isPaused || !job.enabled { + try await appState.caelWorkspaceAPIService.resumeWorkspaceCronJob(connection: connection, jobID: job.id) + } else { + try await appState.caelWorkspaceAPIService.pauseWorkspaceCronJob(connection: connection, jobID: job.id) + } + operationJobID = nil + await loadOperationJobs() + } catch { + operationJobID = nil + errorMessage = error.localizedDescription + } + } + + private func operationSessionKey(for profile: CaelProfileSummary) -> String { + "agent:main:ops-\(profile.name)" + } + + private func loadOperationChat(_ profile: CaelProfileSummary) async { + guard let connection = appState.activeConnection else { return } + do { + let response = try await appState.caelWorkspaceAPIService.loadWorkspaceSessionHistory( + connection: connection, + sessionKey: operationSessionKey(for: profile), + limit: 50 + ) + operationChatMessages[profile.name] = response.messages + operationChatNotices[profile.name] = response.ok == false ? (response.error ?? "Session history unavailable.") : nil + } catch { + operationChatNotices[profile.name] = error.localizedDescription + } + } + + private func sendOperationChat(_ profile: CaelProfileSummary) async { + guard let connection = appState.activeConnection, + let message = operationChatDrafts[profile.name]?.nilIfBlank else { return } + let sessionKey = operationSessionKey(for: profile) + operationSessionKey = sessionKey + operationChatNotices[profile.name] = nil + do { + _ = try await appState.caelWorkspaceAPIService.sendWorkspaceSessionMessage( + connection: connection, + sessionKey: sessionKey, + message: message, + autoApproveCommands: false + ) + operationChatDrafts[profile.name] = "" + operationChatNotices[profile.name] = "Accepted by shared Workspace session runner." + operationSessionKey = nil + await loadOperationChat(profile) + await appState.refreshCaelWorkspace() + } catch { + operationSessionKey = nil + operationChatNotices[profile.name] = error.localizedDescription + } + } + + private func createAgent() async { + guard let connection = appState.activeConnection else { return } + let name = normalizedProfileName(newAgentName) + isCreating = true + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.createProfile( + connection: connection, + name: name, + cloneFrom: "default", + model: newAgentModel.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank + ) + newAgentName = "" + newAgentModel = "" + isCreating = false + await loadAll() + } catch { + isCreating = false + errorMessage = error.localizedDescription + } + } + + private func beginEdit(_ profile: CaelProfileSummary) async { + editModel = profile.model ?? "" + editProvider = profile.provider ?? "" + editDescription = profile.description ?? "" + editSystemPrompt = "" + editingProfile = profile + guard let connection = appState.activeConnection else { return } + isLoadingEditDetail = true + do { + let detail = try await appState.caelWorkspaceAPIService.readProfile(connection: connection, name: profile.name) + guard editingProfile?.name == profile.name else { return } + editModel = stringValue(detail.config["model"]) ?? editModel + editProvider = stringValue(detail.config["provider"]) ?? editProvider + editSystemPrompt = stringValue(detail.config["system_prompt"]) ?? "" + editDescription = detail.description + isLoadingEditDetail = false + } catch { + isLoadingEditDetail = false + errorMessage = error.localizedDescription + } + } + + private func saveProfileEdits() async { + guard let connection = appState.activeConnection, + let editingProfile else { return } + isSavingProfile = true + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.updateProfileOperationsConfig( + connection: connection, + name: editingProfile.name, + model: editModel, + provider: editProvider, + systemPrompt: editSystemPrompt, + description: editDescription + ) + isSavingProfile = false + self.editingProfile = nil + await loadAll() + } catch { + isSavingProfile = false + errorMessage = error.localizedDescription + } + } + + private func activateProfile(_ profile: CaelProfileSummary) async { + guard let connection = appState.activeConnection else { return } + operationProfileName = profile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.activateProfile(connection: connection, name: profile.name) + await appState.switchHermesProfile(to: profile.name) + operationProfileName = nil + await loadAll() + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func deleteProfile(_ profile: CaelProfileSummary) async { + guard let connection = appState.activeConnection else { return } + operationProfileName = profile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.deleteProfile(connection: connection, name: profile.name) + operationProfileName = nil + await loadAll() + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func startRuntime() async { + guard let connection = appState.activeConnection else { return } + isStartingRuntime = true + errorMessage = nil + runtimeMessage = nil + do { + let response = try await appState.caelWorkspaceAPIService.startWorkspaceAgentRuntime(connection: connection) + runtimeMessage = response.pid.map { "\(response.displayMessage) pid \($0)" } ?? response.displayMessage + isStartingRuntime = false + await appState.refreshCaelWorkspace() + await loadAll() + } catch { + isStartingRuntime = false + errorMessage = error.localizedDescription + } + } + + private func crewMember(for profile: CaelProfileSummary) -> WorkspaceCrewMember? { + let crewID = profile.name == "default" ? "workspace" : profile.name + return crew.first { $0.id == crewID } + } + + private func normalizedProfileName(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: #"[^a-z0-9_-]+"#, with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-_")) + } + + private func isValidProfileName(_ value: String) -> Bool { + let normalized = normalizedProfileName(value) + guard !normalized.isEmpty, normalized != "default", normalized.count <= 64 else { return false } + return normalized.range(of: #"^[a-z0-9][a-z0-9_-]*$"#, options: .regularExpression) != nil + } + + private func stringValue(_ value: CaelJSONValue?) -> String? { + guard case let .string(text) = value else { return nil } + return text + } + + private func formatTimestamp(_ value: Double) -> String { + let seconds = value > 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds).formatted(date: .abbreviated, time: .shortened) + } + + private func tint(forStatus status: String) -> Color { + switch status.lowercased() { + case "ready", "available", "complete", "online", "ok", "running": return .green + case "active": return .cyan + case "setup-needed", "warning", "error", "needs_attention", "unknown": return .orange + default: return .blue + } + } +} + +private struct OperationMiniStat: View { + let label: String + let value: String + + var body: some View { + Text("\(value) \(label)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.12), in: Capsule()) + } +} + +private struct NativeSwarmPanel: View { + @EnvironmentObject private var appState: AppState + + @State private var health: WorkspaceSwarmHealthResponse? + @State private var runtime: WorkspaceSwarmRuntimeResponse? + @State private var missions: WorkspaceSwarmMissionsResponse? + @State private var selectedWorkerID: String? + @State private var dispatchPrompt = "Reply with exactly: SWARM_PING_OK" + @State private var statusMessage: String? + @State private var isLoading = false + @State private var profileMemory: WorkspaceSwarmMemoryResponse? + @State private var episodicMemory: WorkspaceSwarmMemoryResponse? + @State private var reportSnapshot: WorkspaceSwarmReportsResponse? + @State private var memorySearchResults: WorkspaceSwarmMemorySearchResponse? + @State private var memorySearchQuery = "" + @State private var isLoadingMemory = false + @State private var mutatingWorkerID: String? + @State private var dispatchingWorkerID: String? + + private var workers: [WorkspaceSwarmWorkerHealth] { + (health?.workers ?? []).sorted { left, right in + swarmSortKey(left.workerId) < swarmSortKey(right.workerId) + } + } + + private var selectedWorker: WorkspaceSwarmWorkerHealth? { + guard let selectedWorkerID else { return workers.first } + return workers.first { $0.workerId == selectedWorkerID } ?? workers.first + } + + var body: some View { + HermesSurfacePanel( + title: "Swarm Runtime", + subtitle: "Native worker health, live runtime, tmux lifecycle, and direct dispatch through the shared Workspace Swarm APIs." + ) { + VStack(alignment: .leading, spacing: 16) { + header + + if let statusMessage { + MirrorRow(title: "Status", detail: statusMessage, tint: statusMessage.lowercased().contains("error") ? .orange : .blue) + } + + workerGrid + dispatchPanel + missionsPanel + memoryReportsPanel + } + } + .task(id: appState.activeConnectionID) { + await loadAll() + } + .onChange(of: selectedWorkerID) { _, workerID in + Task { await loadWorkerMemoryReports(workerID: workerID) } + } + } + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(health?.summary.degraded == true ? "Needs attention" : "Runtime ready") + .font(.headline) + Text(summaryDetail) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isLoading { + ProgressView() + .controlSize(.small) + } + Button("Refresh") { + Task { await loadAll() } + } + .disabled(isLoading) + } + } + + private var summaryDetail: String { + guard let health else { return "Load Swarm health from /api/swarm-health." } + let providers = health.summary.distinctProviders.prefix(3).joined(separator: ", ") + return [ + "\(health.summary.totalWorkers) workers", + "\(health.summary.wrappersConfigured ?? 0) wrappers", + "\(runtime?.entries.filter(\.tmuxAttachable).count ?? 0) tmux attached", + providers.isEmpty ? nil : providers + ].compactMap { $0 }.joined(separator: " · ") + } + + private var workerGrid: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Workers") + .font(.headline) + Spacer() + if let runtime { + Text(runtime.tmuxAvailable ? "tmux available" : "tmux unavailable") + .font(.caption) + .foregroundStyle(runtime.tmuxAvailable ? Color.green : Color.orange) + } + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(workers) { worker in + workerCard(worker) + } + } + + if health != nil && workers.isEmpty { + Text("No swarm workers reported by the active Workspace.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + private func workerCard(_ worker: WorkspaceSwarmWorkerHealth) -> some View { + let runtimeEntry = runtimeEntry(for: worker.workerId) + let isSelected = selectedWorker?.workerId == worker.workerId + return MirrorListCard(title: worker.humanLabel, emptyText: "No worker detail reported.") { + MirrorRow( + title: runtimeEntry?.state ?? worker.modelAuthStatus, + detail: workerDetail(worker, runtimeEntry: runtimeEntry), + badge: runtimeEntry?.tmuxAttachable == true ? "tmux" : (worker.wrapperFound ? "ready" : "setup"), + tint: workerTint(worker, runtimeEntry: runtimeEntry) + ) + if let currentTask = runtimeEntry?.currentTask?.nilIfBlank { + MirrorRow(title: "Current task", detail: currentTask, tint: .blue) + } + if let tail = runtimeEntry?.recentLogTail?.nilIfBlank { + MirrorRow(title: "Recent log", detail: preview(tail, maxLength: 260), tint: .secondary) + } + + HStack(spacing: 8) { + Button(isSelected ? "Selected" : "Select") { + selectedWorkerID = worker.workerId + } + + Button(runtimeEntry?.tmuxAttachable == true ? "Attach Ready" : "Start tmux") { + Task { await startWorker(worker.workerId) } + } + .disabled(mutatingWorkerID != nil || runtimeEntry?.tmuxAttachable == true) + + Button("Stop", role: .destructive) { + Task { await stopWorker(worker.workerId) } + } + .disabled(mutatingWorkerID != nil || runtimeEntry?.tmuxAttachable != true) + } + .font(.caption) + } + } + + private var dispatchPanel: some View { + MirrorListCard(title: "Direct Dispatch", emptyText: "Select a worker before dispatching.") { + if let worker = selectedWorker { + MirrorRow( + title: worker.workerId, + detail: worker.mission ?? worker.specialty ?? worker.role, + badge: dispatchingWorkerID == worker.workerId ? "Sending" : "Selected", + tint: .cyan + ) + + TextEditor(text: $dispatchPrompt) + .font(.body) + .frame(height: 88) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.18), lineWidth: 1) + } + + HStack { + Spacer() + Button(dispatchingWorkerID == worker.workerId ? "Dispatching..." : "Dispatch to \(worker.workerId)") { + Task { await dispatch(to: worker.workerId) } + } + .buttonStyle(.borderedProminent) + .disabled(dispatchingWorkerID != nil || dispatchPrompt.nilIfBlank == nil) + } + } else { + Text("No workers available.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var missionsPanel: some View { + MirrorListCard(title: "Recent Missions", emptyText: "No swarm missions reported.") { + let missionList = missions?.missions ?? [] + if missionList.isEmpty { + Text("Mission history is loaded from /api/swarm-missions.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(missionList.prefix(6)) { mission in + MirrorRow( + title: mission.title, + detail: "\(mission.assignments.count) assignments · \(mission.updatedAt ?? mission.createdAt ?? "unknown time")", + badge: mission.state, + tint: tint(forStatus: mission.state) + ) + } + } + } + } + + private var memoryReportsPanel: some View { + MirrorListCard(title: "Worker Memory + Reports", emptyText: "Select a worker to inspect memory and reports.") { + if let worker = selectedWorker { + HStack(alignment: .firstTextBaseline, spacing: 10) { + MirrorRow( + title: worker.workerId, + detail: "Reads profile memory, episodic memory, search results, and checkpoint reports through shared Swarm APIs.", + badge: isLoadingMemory ? "Loading" : "Selected", + tint: .purple + ) + Spacer() + Button("Refresh") { + Task { await loadWorkerMemoryReports(workerID: worker.workerId) } + } + .disabled(isLoadingMemory) + } + + HStack(spacing: 8) { + TextField("Search worker memory", text: $memorySearchQuery) + .textFieldStyle(.roundedBorder) + Button("Search") { + Task { await searchMemory(workerID: worker.workerId) } + } + .disabled(isLoadingMemory || memorySearchQuery.nilIfBlank == nil) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + memoryCard(title: "Profile Memory", memory: profileMemory) + memoryCard(title: "Episodic Memory", memory: episodicMemory) + reportsCard + searchResultsCard + } + } else { + Text("No workers available.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func memoryCard(title: String, memory: WorkspaceSwarmMemoryResponse?) -> some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.subheadline.weight(.semibold)) + if let error = memory?.error?.nilIfBlank { + Text(error) + .font(.caption) + .foregroundStyle(.orange) + } else if let files = memory?.files, !files.isEmpty { + ForEach(files.prefix(3)) { file in + VStack(alignment: .leading, spacing: 4) { + Text(file.name) + .font(.caption.weight(.semibold)) + Text(preview(file.content, maxLength: 220)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + if files.count > 3 { + Text("+ \(files.count - 3) more memory files") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } else { + Text(memory == nil ? "Not loaded yet." : "No memory files reported.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var reportsCard: some View { + VStack(alignment: .leading, spacing: 9) { + Text("Checkpoint Reports") + .font(.subheadline.weight(.semibold)) + let reports = reportSnapshot?.reports ?? [] + if reports.isEmpty { + Text(reportSnapshot == nil ? "Not loaded yet." : "No checkpoint reports for this worker.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(reports.prefix(4)) { report in + VStack(alignment: .leading, spacing: 4) { + Text(reportTitle(report)) + .font(.caption.weight(.semibold)) + Text(reportDetail(report)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var searchResultsCard: some View { + VStack(alignment: .leading, spacing: 9) { + Text("Memory Search") + .font(.subheadline.weight(.semibold)) + let results = memorySearchResults?.results ?? [] + if results.isEmpty { + Text(memorySearchResults == nil ? "Run a search to inspect worker memory." : "No matching memory snippets.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(results.prefix(5)) { result in + VStack(alignment: .leading, spacing: 4) { + Text("\(result.path):\(result.line)") + .font(.caption.weight(.semibold)) + Text(preview(result.snippet, maxLength: 180)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func loadAll() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + statusMessage = nil + defer { isLoading = false } + do { + async let healthResponse = appState.caelWorkspaceAPIService.loadSwarmHealth(connection: connection) + async let runtimeResponse = appState.caelWorkspaceAPIService.loadSwarmRuntime(connection: connection) + async let missionsResponse = appState.caelWorkspaceAPIService.loadSwarmMissions(connection: connection, limit: 8) + let (loadedHealth, loadedRuntime, loadedMissions) = try await (healthResponse, runtimeResponse, missionsResponse) + health = loadedHealth + runtime = loadedRuntime + missions = loadedMissions + if selectedWorkerID == nil { + selectedWorkerID = loadedHealth.workers.first?.workerId + } + statusMessage = "Loaded \(loadedHealth.summary.totalWorkers) workers and \(loadedMissions.missions.count) recent missions." + await loadWorkerMemoryReports(workerID: selectedWorkerID) + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func loadWorkerMemoryReports(workerID: String?) async { + guard let connection = appState.activeConnection, + let workerID = workerID?.nilIfBlank else { return } + isLoadingMemory = true + defer { isLoadingMemory = false } + async let profile: WorkspaceSwarmMemoryResponse? = try? appState.caelWorkspaceAPIService.loadSwarmMemory( + connection: connection, + workerID: workerID, + kind: "profile" + ) + async let episodic: WorkspaceSwarmMemoryResponse? = try? appState.caelWorkspaceAPIService.loadSwarmMemory( + connection: connection, + workerID: workerID, + kind: "episodic" + ) + async let reports: WorkspaceSwarmReportsResponse? = try? appState.caelWorkspaceAPIService.loadSwarmReports( + connection: connection, + workerID: workerID, + limit: 8 + ) + profileMemory = await profile + episodicMemory = await episodic + reportSnapshot = await reports + } + + private func searchMemory(workerID: String) async { + guard let connection = appState.activeConnection, + let query = memorySearchQuery.nilIfBlank else { return } + isLoadingMemory = true + defer { isLoadingMemory = false } + do { + memorySearchResults = try await appState.caelWorkspaceAPIService.searchSwarmMemory( + connection: connection, + workerID: workerID, + query: query, + scope: "worker", + limit: 10 + ) + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func startWorker(_ workerID: String) async { + guard let connection = appState.activeConnection else { return } + mutatingWorkerID = workerID + statusMessage = nil + defer { mutatingWorkerID = nil } + do { + let response = try await appState.caelWorkspaceAPIService.startSwarmWorkerTmux(connection: connection, workerID: workerID) + statusMessage = response.alreadyRunning == true ? "\(workerID) already had a tmux session." : "Started \(response.sessionName ?? workerID)." + await loadAll() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func stopWorker(_ workerID: String) async { + guard let connection = appState.activeConnection else { return } + mutatingWorkerID = workerID + statusMessage = nil + defer { mutatingWorkerID = nil } + do { + let response = try await appState.caelWorkspaceAPIService.stopSwarmWorkerTmux(connection: connection, workerID: workerID) + statusMessage = response.wasRunning == true ? "Stopped \(response.sessionName ?? workerID)." : "\(workerID) was not running." + await loadAll() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func dispatch(to workerID: String) async { + guard let connection = appState.activeConnection, let prompt = dispatchPrompt.nilIfBlank else { return } + dispatchingWorkerID = workerID + statusMessage = nil + defer { dispatchingWorkerID = nil } + do { + let response = try await appState.caelWorkspaceAPIService.dispatchSwarmPrompt( + connection: connection, + workerID: workerID, + prompt: prompt, + timeoutSeconds: 60, + allowAsync: false + ) + if let result = response.results?.first { + statusMessage = result.ok + ? "\(workerID) replied in \(String(format: "%.1f", (result.durationMs ?? 0) / 1000))s: \(preview(result.output, maxLength: 120))" + : "\(workerID) failed: \(result.error ?? "unknown error")" + } else { + statusMessage = response.ok == false ? (response.error ?? "Dispatch failed.") : "Dispatch accepted." + } + await loadAll() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func runtimeEntry(for workerID: String) -> WorkspaceSwarmRuntimeEntry? { + runtime?.entries.first { $0.workerId == workerID } + } + + private func workerDetail(_ worker: WorkspaceSwarmWorkerHealth, runtimeEntry: WorkspaceSwarmRuntimeEntry?) -> String { + [ + worker.specialty?.nilIfBlank ?? worker.role, + "\(worker.provider) / \(worker.model)", + runtimeEntry?.tmuxSession?.nilIfBlank.map { "tmux: \($0)" }, + runtimeEntry?.lastOutputAt.map { "last output \(formatTimestamp($0))" }, + worker.lastErrorMessage?.nilIfBlank + ].compactMap { $0 }.joined(separator: "\n") + } + + private func swarmSortKey(_ workerID: String) -> String { + let number = Int(workerID.replacingOccurrences(of: #"^\D+"#, with: "", options: .regularExpression)) ?? 9999 + return String(format: "%04d-%@", number, workerID) + } + + private func workerTint(_ worker: WorkspaceSwarmWorkerHealth, runtimeEntry: WorkspaceSwarmRuntimeEntry?) -> Color { + if worker.recentAuthErrors > 0 || worker.fallbackActive || runtimeEntry?.needsHuman == true { return .orange } + if runtimeEntry?.tmuxAttachable == true || runtimeEntry?.state.lowercased() == "running" { return .green } + if worker.wrapperFound { return .blue } + return .secondary + } + + private func tint(forStatus status: String) -> Color { + switch status.lowercased() { + case "complete", "done", "running", "executing": return .green + case "blocked", "failed", "cancelled", "error": return .orange + default: return .blue + } + } + + private func reportTitle(_ report: WorkspaceSwarmReport) -> String { + let status = report.checkpointStatus?.nilIfBlank ?? report.stateLabel?.nilIfBlank ?? "checkpoint" + return "\(report.workerId) · \(status)" + } + + private func reportDetail(_ report: WorkspaceSwarmReport) -> String { + [ + report.recordedAt.map { formatTimestamp($0) }, + report.result?.nilIfBlank.map { "Result: \(preview($0, maxLength: 140))" }, + report.blocker?.nilIfBlank.map { "Blocker: \(preview($0, maxLength: 140))" }, + report.nextAction?.nilIfBlank.map { "Next: \(preview($0, maxLength: 140))" }, + report.commandsRun?.nilIfBlank.map { "Commands: \(preview($0, maxLength: 100))" }, + report.filesChanged?.nilIfBlank.map { "Files: \(preview($0, maxLength: 100))" } + ].compactMap { $0 }.joined(separator: "\n") + } + + private func formatTimestamp(_ value: Double) -> String { + let seconds = value > 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds).formatted(date: .abbreviated, time: .shortened) + } + + private func preview(_ value: String, maxLength: Int) -> String { + let normalized = value + .replacingOccurrences(of: "\n\n", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.count <= maxLength { return normalized.isEmpty ? "No preview available." : normalized } + let index = normalized.index(normalized.startIndex, offsetBy: maxLength) + return String(normalized[.. String { + [ + response.modeNote?.nilIfBlank, + response.sessionKey?.nilIfBlank.map { "session: \($0)" }, + response.assignments.map { "\($0.count) assignments" }, + (response.warnings?.joined(separator: "\n"))?.nilIfBlank + ].compactMap { $0 }.joined(separator: "\n") + } + + private func tint(forStatus status: String) -> Color { + switch status.lowercased() { + case "complete", "completed", "running", "executing": return .green + case "blocked", "failed", "cancelled", "error": return .orange + default: return .blue + } + } +} + +private struct NativeKnowledgeFabricPanel: View { + @EnvironmentObject private var appState: AppState + + @State private var query = "Hermes Cael parity" + @State private var scope = "both" + @State private var mode = "knowledge" + @State private var agentSource = "" + @State private var documentID = "" + @State private var health: KnowledgeFabricHealthResponse? + @State private var searchResponse: KnowledgeFabricSearchResponse? + @State private var documentResponse: KnowledgeFabricSearchResponse? + @State private var sessionStateResponse: KnowledgeFabricSessionStateResponse? + @State private var sessionSummary = "" + @State private var sessionScope = "business" + @State private var sessionAgentSource = "Cael Desktop" + @State private var sessionID = "" + @State private var sessionProject = "Hermes/Cael" + @State private var errorMessage: String? + @State private var isLoadingHealth = false + @State private var isSearching = false + @State private var isLookingUpDocument = false + @State private var isRecordingSessionState = false + + var body: some View { + HermesSurfacePanel( + title: "Knowledge Fabric", + subtitle: "Search the scoped second brain through the shared :3077 Memory Fabric contract." + ) { + VStack(alignment: .leading, spacing: 16) { + headerRow + searchControls + + if let errorMessage { + MirrorRow(title: "Error", detail: errorMessage, badge: "Attention", tint: .orange) + } + + if let searchResponse { + resultSection(title: "Search Results", response: searchResponse) + } + + documentControls + + if let documentResponse { + resultSection(title: "Document Lookup", response: documentResponse) + } + + sessionStateControls + + if let sessionStateResponse { + MirrorRow( + title: sessionStateResponse.ok == false ? "Session state failed" : "Session state recorded", + detail: sessionStateResponse.data ?? sessionStateResponse.error ?? "Knowledge Fabric accepted the session-state receipt.", + badge: sessionStateResponse.ok == false ? "Error" : "Memory", + tint: sessionStateResponse.ok == false ? .orange : .green + ) + } + } + } + .task(id: appState.activeConnectionID) { + await refreshHealth() + } + } + + private var headerRow: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(health?.statusLabel ?? "Checking") + .font(.subheadline.weight(.semibold)) + Text(health?.endpoint ?? "https://memory.visualgraphx.com") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 12) + if isLoadingHealth { + ProgressView() + .controlSize(.small) + } + Button("Refresh") { + Task { await refreshHealth() } + } + .disabled(isLoadingHealth) + } + } + + private var searchControls: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("Search Memory Fabric", text: $query) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await runSearch() } + } + + HStack(alignment: .center, spacing: 12) { + Picker("Scope", selection: $scope) { + Text("Both").tag("both") + Text("Business").tag("business") + Text("Personal").tag("personal") + } + .pickerStyle(.segmented) + .frame(maxWidth: 360) + + Picker("Mode", selection: $mode) { + Text("Knowledge").tag("knowledge") + Text("Agent").tag("agent") + } + .pickerStyle(.segmented) + .frame(maxWidth: 260) + + if mode == "agent" { + TextField("Agent source", text: $agentSource) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 180) + } + + Button("Search") { + Task { await runSearch() } + } + .buttonStyle(.borderedProminent) + .disabled(isSearching || query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if isSearching { + ProgressView("Searching Knowledge Fabric...") + .controlSize(.small) + } + } + } + + private var documentControls: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Document lookup") + .font(.headline) + HStack(spacing: 12) { + TextField("doc id or canonical id", text: $documentID) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await lookupDocument() } + } + Button("Lookup") { + Task { await lookupDocument() } + } + .disabled(isLookingUpDocument || documentID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + if isLookingUpDocument { + ProgressView("Loading document...") + .controlSize(.small) + } + } + } + + private var sessionStateControls: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Record session state") + .font(.headline) + TextEditor(text: $sessionSummary) + .font(.body) + .frame(minHeight: 88) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.18), lineWidth: 1) + } + HStack(spacing: 12) { + Picker("Scope", selection: $sessionScope) { + Text("Business").tag("business") + Text("Personal").tag("personal") + } + .pickerStyle(.segmented) + .frame(maxWidth: 240) + TextField("Agent source", text: $sessionAgentSource) + .textFieldStyle(.roundedBorder) + TextField("Session id", text: $sessionID) + .textFieldStyle(.roundedBorder) + TextField("Project", text: $sessionProject) + .textFieldStyle(.roundedBorder) + Button(isRecordingSessionState ? "Recording..." : "Record") { + Task { await recordSessionState() } + } + .buttonStyle(.borderedProminent) + .disabled(isRecordingSessionState || sessionSummary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + private func resultSection(title: String, response: KnowledgeFabricSearchResponse) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + let results = response.scopedResults + if results.isEmpty { + Text("No scoped results returned.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(results) { result in + resultCard(result) + } + } + } + } + } + + private func resultCard(_ result: KnowledgeFabricScopedResult) -> some View { + MirrorListCard(title: result.displayScope, emptyText: "No Knowledge Fabric detail reported.") { + MirrorRow( + title: result.ok == false ? "Unavailable" : "Result", + detail: preview(result.summary, maxLength: 600), + badge: result.ok == false ? "Error" : "Memory", + tint: result.ok == false ? .orange : .green + ) + + let evidence = result.data?.evidence ?? [] + ForEach(Array(evidence.prefix(4))) { item in + VStack(alignment: .leading, spacing: 8) { + Text(item.title ?? item.docID ?? "Evidence") + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(preview(item.snippet ?? item.canonicalDocID ?? "", maxLength: 260)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let docID = item.docID ?? item.canonicalDocID { + Button("Lookup document") { + documentID = docID + scope = result.scopeKey == "personal" ? "personal" : "business" + Task { await lookupDocument() } + } + .buttonStyle(.borderless) + } + } + .padding(.top, 4) + } + } + } + + private func refreshHealth() async { + guard let connection = appState.activeConnection else { return } + isLoadingHealth = true + defer { isLoadingHealth = false } + do { + health = try await appState.caelWorkspaceAPIService.loadKnowledgeFabricHealth(connection: connection) + } catch { + errorMessage = error.localizedDescription + } + } + + private func runSearch() async { + guard let connection = appState.activeConnection else { return } + isSearching = true + errorMessage = nil + defer { isSearching = false } + do { + searchResponse = try await appState.caelWorkspaceAPIService.searchKnowledgeFabric( + connection: connection, + query: query, + scope: scope, + mode: mode, + agentSource: agentSource + ) + } catch { + errorMessage = error.localizedDescription + } + } + + private func lookupDocument() async { + guard let connection = appState.activeConnection else { return } + isLookingUpDocument = true + errorMessage = nil + defer { isLookingUpDocument = false } + do { + documentResponse = try await appState.caelWorkspaceAPIService.lookupKnowledgeFabricDocument( + connection: connection, + docID: documentID, + scope: scope + ) + } catch { + errorMessage = error.localizedDescription + } + } + + private func recordSessionState() async { + guard let connection = appState.activeConnection else { return } + isRecordingSessionState = true + errorMessage = nil + defer { isRecordingSessionState = false } + do { + sessionStateResponse = try await appState.caelWorkspaceAPIService.recordKnowledgeFabricSessionState( + connection: connection, + summary: sessionSummary, + memoryScope: sessionScope, + agentSource: sessionAgentSource, + sessionId: sessionID, + project: sessionProject + ) + sessionSummary = "" + } catch { + errorMessage = error.localizedDescription + } + } + + private func preview(_ value: String, maxLength: Int) -> String { + let normalized = value + .replacingOccurrences(of: "\n\n", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.count <= maxLength { return normalized.isEmpty ? "No preview available." : normalized } + let index = normalized.index(normalized.startIndex, offsetBy: maxLength) + return String(normalized[.. some View { + MirrorListCard(title: server.name, emptyText: "No MCP server detail reported.") { + MirrorRow( + title: server.status, + detail: serverDetail(server), + badge: server.enabled ? "Enabled" : "Disabled", + tint: tint(server.status) + ) + MirrorRow(title: "Tools", detail: "\(server.discoveredToolsCount) discovered", badge: server.transportType, tint: .blue) + if let result = testResults[server.name] { + MirrorRow( + title: "Last test: \(result.status)", + detail: testDetail(result), + badge: "\(result.discoveredTools.count) tools", + tint: result.ok ? .green : .orange + ) + } + if let discovery = discoverResults[server.name] { + MirrorRow( + title: "Discovery", + detail: discoverDetail(discovery), + badge: "\(discovery.tools.count) tools", + tint: discovery.ok ? .green : .orange + ) + } + if let logs = logResults[server.name] { + MirrorRow( + title: logs.ok ? "Log tail" : "Log tail unavailable", + detail: logDetail(logs), + badge: logs.lines.isEmpty ? nil : "\(logs.lines.count) lines", + tint: logs.ok ? .blue : .orange + ) + } + HStack(spacing: 8) { + Button(testingServer == server.name ? "Testing..." : "Test") { + Task { await testServer(server.name) } + } + .disabled(!isMCPCapabilityAvailable || testingServer != nil || !server.enabled) + + Button(discoveringServer == server.name ? "Discovering..." : "Discover") { + Task { await discoverServer(server) } + } + .disabled(!isMCPCapabilityAvailable || discoveringServer != nil || !server.enabled) + + Button(loadingLogsServer == server.name ? "Loading..." : "Logs") { + Task { await loadLogs(server.name) } + } + .disabled(loadingLogsServer != nil) + + Button(server.enabled ? "Disable" : "Enable") { + Task { await setServer(server.name, enabled: !server.enabled) } + } + .disabled(!isMCPCapabilityAvailable || mutatingServer != nil) + + Button("Delete", role: .destructive) { + Task { await deleteServer(server.name) } + } + .disabled(!isMCPCapabilityAvailable || mutatingServer != nil) + } + } + } + + private func loadServers() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + response = try await appState.caelWorkspaceAPIService.listMCPServers( + connection: connection, + search: search, + category: category + ) + if response?.ok == false { + statusMessage = response?.error ?? "MCP capability unavailable on the active Workspace gateway." + } else { + statusMessage = "Loaded \(response?.total ?? servers.count) MCP servers." + } + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func createServer() async { + guard let connection = appState.activeConnection else { return } + let name = newServerName.trimmingCharacters(in: .whitespacesAndNewlines) + let command = newServerCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !command.isEmpty else { return } + mutatingServer = "__create__" + defer { mutatingServer = nil } + do { + _ = try await appState.caelWorkspaceAPIService.createMCPCommandServer( + connection: connection, + name: name, + command: command, + args: shellWords(newServerArgs), + enabled: newServerEnabled + ) + newServerName = "" + newServerCommand = "" + newServerArgs = "" + statusMessage = "Added MCP server \(name)." + await loadServers() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func setServer(_ name: String, enabled: Bool) async { + guard let connection = appState.activeConnection else { return } + mutatingServer = name + defer { mutatingServer = nil } + do { + _ = try await appState.caelWorkspaceAPIService.configureMCPServer( + connection: connection, + name: name, + enabled: enabled + ) + statusMessage = "Updated MCP server \(name)." + await loadServers() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func deleteServer(_ name: String) async { + guard let connection = appState.activeConnection else { return } + mutatingServer = name + defer { mutatingServer = nil } + do { + _ = try await appState.caelWorkspaceAPIService.deleteMCPServer(connection: connection, name: name) + testResults[name] = nil + discoverResults[name] = nil + logResults[name] = nil + statusMessage = "Deleted MCP server \(name)." + await loadServers() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func shellWords(_ value: String) -> [String] { + value + .split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" }) + .map(String.init) + } + + private func testServer(_ name: String) async { + guard let connection = appState.activeConnection else { return } + testingServer = name + defer { testingServer = nil } + do { + let result = try await appState.caelWorkspaceAPIService.testMCPServer(connection: connection, name: name) + testResults[name] = result + statusMessage = "Tested \(name): \(result.status)." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func loadMCPRegistry() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + async let sourcesResponse = appState.caelWorkspaceAPIService.loadMCPHubSources(connection: connection) + async let presetsResponse = appState.caelWorkspaceAPIService.loadMCPPresets(connection: connection) + let (sources, loadedPresets) = try await (sourcesResponse, presetsResponse) + hubSources = sources.sources + presets = loadedPresets.presets + let sourceWarning = sources.ok == false ? sources.error?.nilIfBlank : nil + let presetWarning = loadedPresets.ok == false ? loadedPresets.error?.nilIfBlank : nil + statusMessage = [ + "Loaded \(hubSources.count) MCP hub sources and \(presets.count) presets.", + sourceWarning, + presetWarning + ].compactMap { $0 }.joined(separator: " ") + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func discoverServer(_ server: WorkspaceMCPServer) async { + guard let connection = appState.activeConnection else { return } + discoveringServer = server.name + defer { discoveringServer = nil } + do { + let result = try await appState.caelWorkspaceAPIService.discoverMCPServer(connection: connection, server: server) + discoverResults[server.name] = result + statusMessage = "Discovered \(result.tools.count) tools for \(server.name)." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func loadLogs(_ name: String) async { + guard let connection = appState.activeConnection else { return } + loadingLogsServer = name + defer { loadingLogsServer = nil } + do { + let result = try await appState.caelWorkspaceAPIService.loadMCPServerLogs(connection: connection, name: name) + logResults[name] = result + if result.ok { + statusMessage = result.lines.isEmpty ? "No recent MCP logs for \(name)." : "Loaded \(result.lines.count) log lines for \(name)." + } else { + statusMessage = "MCP logs unavailable for \(name): \(result.error ?? "unknown error")." + } + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func searchMCPMarketplace() async { + guard let connection = appState.activeConnection else { return } + let query = hubSearch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + isSearchingHub = true + defer { isSearchingHub = false } + do { + let result = try await appState.caelWorkspaceAPIService.searchMCPHub(connection: connection, query: query) + hubSearchResults = result.results + let warning = result.warnings?.first?.nilIfBlank + hubSearchSummary = [ + "\(result.results.count) of \(result.total ?? result.results.count) results from \(result.source ?? "hub").", + result.ok == false ? result.error?.nilIfBlank : nil, + warning + ].compactMap { $0 }.joined(separator: " ") + } catch { + hubSearchSummary = "Error: \(error.localizedDescription)" + } + } + + private func stageHubEntry(_ entry: WorkspaceMCPHubEntry) { + guard let template = entry.template, let command = template.command?.nilIfBlank else { + statusMessage = "Only command-backed MCP templates can be staged in Desktop right now." + return + } + newServerName = template.name?.nilIfBlank ?? entry.name + newServerCommand = command + newServerArgs = (template.args ?? []).joined(separator: " ") + newServerEnabled = true + statusMessage = "Staged MCP template \(entry.name). Review the Add command MCP server form before adding." + } + + private func hubEntryDetail(_ entry: WorkspaceMCPHubEntry) -> String { + [ + entry.description?.nilIfBlank, + entry.source?.nilIfBlank.map { "Source: \($0)" }, + entry.tags?.prefix(4).joined(separator: ", ").nilIfBlank + ].compactMap { $0 }.joined(separator: "\n") + } + + private func serverDetail(_ server: WorkspaceMCPServer) -> String { + let target = server.url ?? [server.command, server.args.joined(separator: " ")].compactMap { $0 }.joined(separator: " ") + if let lastError = server.lastError, !lastError.isEmpty { + return "\(target)\n\(lastError)" + } + return target.isEmpty ? "No target reported." : target + } + + private func testDetail(_ result: WorkspaceMCPTestResponse) -> String { + if let error = result.error, !error.isEmpty { return error } + let tools = result.discoveredTools.prefix(6).map(\.name).joined(separator: ", ") + if !tools.isEmpty { return tools } + if let latency = result.latencyMs { return "\(Int(latency)) ms" } + return "No tools reported." + } + + private func discoverDetail(_ result: WorkspaceMCPDiscoverResponse) -> String { + if let error = result.error, !error.isEmpty { return error } + let tools = result.tools.prefix(8).map(\.name).joined(separator: ", ") + return tools.isEmpty ? "No tools reported by discovery." : tools + } + + private func logDetail(_ result: WorkspaceMCPLogsResponse) -> String { + if !result.lines.isEmpty { + return result.lines.suffix(12).joined(separator: "\n") + } + if let error = result.error, !error.isEmpty { return error } + return "The shared logs endpoint opened, but no log lines were emitted before the desktop timeout." + } + + private func registrySourceDetail(_ source: WorkspaceMCPHubSource) -> String { + [ + source.url, + source.format?.nilIfBlank.map { "format: \($0)" }, + source.builtin == true ? "built-in" : nil + ].compactMap { $0 }.joined(separator: "\n") + } + + private func presetDetail(_ preset: WorkspaceMCPPreset) -> String { + let template = preset.template + let target = template?.url?.nilIfBlank ?? [template?.command, template?.args?.joined(separator: " ")] + .compactMap { $0?.nilIfBlank } + .joined(separator: " ") + .nilIfBlank + return [ + preset.description?.nilIfBlank, + target, + template?.transportType?.nilIfBlank.map { "transport: \($0)" } + ].compactMap { $0 }.joined(separator: "\n") + } + + private func tint(_ status: String) -> Color { + switch status.lowercased() { + case "connected": return .green + case "failed": return .orange + default: return .secondary + } + } +} + +private struct NativeMemoryKnowledgeFilesPanel: View { + @EnvironmentObject private var appState: AppState + + @State private var tab = "memory" + @State private var query = "Hermes Cael" + @State private var memoryFiles: [WorkspaceMemoryFile] = [] + @State private var memoryMatches: [WorkspaceMemorySearchMatch] = [] + @State private var knowledgePages: [WorkspaceKnowledgePage] = [] + @State private var knowledgeMatches: [WorkspaceKnowledgeSearchMatch] = [] + @State private var selectedTitle = "" + @State private var selectedPath = "" + @State private var selectedContent = "" + @State private var secondBrainSources: [WorkspaceSecondBrainSource] = [] + @State private var secondBrainSourceID = "" + @State private var secondBrainFolder = "" + @State private var secondBrainEntries: [WorkspaceSecondBrainEntry] = [] + @State private var secondBrainHash = "" + @State private var secondBrainDispatchOperation = "ingest" + @State private var secondBrainDispatchResponse: WorkspaceSecondBrainDispatchResponse? + @State private var statusMessage: String? + @State private var isLoading = false + @State private var isSaving = false + @State private var isDispatchingSecondBrainWorkflow = false + + private var activeSecondBrainSource: WorkspaceSecondBrainSource? { + secondBrainSources.first { $0.id == secondBrainSourceID } + } + + var body: some View { + HermesSurfacePanel( + title: "Memory / Knowledge Files", + subtitle: "Native file-backed memory, wiki, and second-brain actions through the shared Workspace API." + ) { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Picker("Source", selection: $tab) { + Text("Memory").tag("memory") + Text("Knowledge").tag("knowledge") + Text("Second Brain").tag("second") + } + .pickerStyle(.segmented) + .frame(maxWidth: 420) + + Button("Refresh") { + Task { await refreshActiveTab() } + } + .disabled(isLoading) + + if isLoading { + ProgressView() + .controlSize(.small) + } + } + + if let statusMessage { + MirrorRow(title: "Status", detail: statusMessage, tint: statusMessage.lowercased().contains("error") ? .orange : .blue) + } + + switch tab { + case "knowledge": + knowledgeBody + case "second": + secondBrainBody + default: + memoryBody + } + } + } + .task(id: appState.activeConnectionID) { + await refreshActiveTab() + } + .onChange(of: tab) { _, _ in + Task { await refreshActiveTab() } + } + } + + private var memoryBody: some View { + VStack(alignment: .leading, spacing: 12) { + searchRow(placeholder: "Search memory files") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(memoryFiles.prefix(8)) { file in + Button { + Task { await readMemory(file.path, title: file.name) } + } label: { + MirrorRow(title: file.name, detail: file.path, badge: formatBytes(file.size), tint: .blue) + } + .buttonStyle(.plain) + } + } + if !memoryMatches.isEmpty { + resultRows(memoryMatches.map { ($0.path, "Line \($0.line)", $0.text) }) { path in + Task { await readMemory(path, title: path) } + } + } + previewEditor(readOnly: true) + } + } + + private var knowledgeBody: some View { + VStack(alignment: .leading, spacing: 12) { + searchRow(placeholder: "Search knowledge wiki") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(knowledgePages.prefix(8)) { page in + Button { + Task { await readKnowledge(page.path, title: page.title) } + } label: { + MirrorRow(title: page.title, detail: page.summary ?? page.path, badge: page.status ?? "wiki", tint: .purple) + } + .buttonStyle(.plain) + } + } + if !knowledgeMatches.isEmpty { + resultRows(knowledgeMatches.map { ($0.path, $0.title, $0.text) }) { path in + Task { await readKnowledge(path, title: path) } + } + } + previewEditor(readOnly: true) + } + } + + private var secondBrainBody: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Picker("Second Brain Source", selection: $secondBrainSourceID) { + ForEach(secondBrainSources) { source in + Text("\(source.label) (\(source.status))").tag(source.id) + } + } + .frame(maxWidth: 420) + + Button("Up") { + secondBrainFolder = parentPath(secondBrainFolder) + Task { await listSecondBrainEntries() } + } + .disabled(secondBrainFolder.isEmpty) + + Text("/\(secondBrainFolder)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let activeSecondBrainSource { + MirrorRow( + title: activeSecondBrainSource.category.capitalized, + detail: activeSecondBrainSource.description, + badge: activeSecondBrainSource.writable ? "Writable" : "Read-only", + tint: activeSecondBrainSource.writable ? .green : .secondary + ) + + secondBrainDispatchControls(activeSecondBrainSource) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(secondBrainEntries.prefix(12)) { entry in + Button { + if entry.type == "folder" { + secondBrainFolder = entry.path + Task { await listSecondBrainEntries() } + } else { + Task { await readSecondBrain(entry.path) } + } + } label: { + MirrorRow( + title: entry.name, + detail: entry.ref, + badge: entry.type == "folder" ? "Folder" : formatBytes(entry.size ?? 0), + tint: entry.type == "folder" ? .cyan : .blue + ) + } + .buttonStyle(.plain) + } + } + + previewEditor(readOnly: activeSecondBrainSource?.writable != true) { + Task { await saveSecondBrain() } + } + } + .onChange(of: secondBrainSourceID) { _, _ in + secondBrainFolder = "" + Task { await listSecondBrainEntries() } + } + } + + private func secondBrainDispatchControls(_ source: WorkspaceSecondBrainSource) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Picker("Workflow", selection: $secondBrainDispatchOperation) { + Text("Ingest").tag("ingest") + Text("Update").tag("update") + Text("Reclass").tag("reclass") + Text("Reingest").tag("reingest") + Text("Rank").tag("rank") + } + .pickerStyle(.segmented) + .frame(maxWidth: 420) + + Button { + Task { await dispatchSecondBrainWorkflow() } + } label: { + Label(isDispatchingSecondBrainWorkflow ? "Dispatching" : "Dispatch Workflow", systemImage: "paperplane") + } + .buttonStyle(.borderedProminent) + .disabled(isDispatchingSecondBrainWorkflow || source.status != "available") + + if isDispatchingSecondBrainWorkflow { + ProgressView() + .controlSize(.small) + } + } + + Text(selectedPath.isEmpty ? "Dispatches a source-level workflow through the server-side second-brain registry." : "Dispatch target: \(selectedPath)") + .font(.caption) + .foregroundStyle(.secondary) + + if let response = secondBrainDispatchResponse { + MirrorRow( + title: response.status ?? "dispatch", + detail: secondBrainDispatchDetail(response), + badge: response.n8n?.configured == true ? "n8n" : "Dry run", + tint: response.ok ? .green : .orange + ) + } + } + } + + private func searchRow(placeholder: String) -> some View { + HStack(spacing: 12) { + TextField(placeholder, text: $query) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await runSearch() } + } + Button("Search") { + Task { await runSearch() } + } + .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private func resultRows(_ rows: [(String, String, String)], onSelect: @escaping (String) -> Task) -> some View { + MirrorListCard(title: "Search Results", emptyText: "No search results.") { + ForEach(Array(rows.prefix(8)), id: \.0) { row in + Button { + _ = onSelect(row.0) + } label: { + MirrorRow(title: row.1, detail: "\(row.0)\n\(preview(row.2, maxLength: 180))", tint: .green) + } + .buttonStyle(.plain) + } + } + } + + private func previewEditor(readOnly: Bool, saveAction: (() -> Void)? = nil) -> some View { + VStack(alignment: .leading, spacing: 8) { + if !selectedTitle.isEmpty { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(selectedTitle) + .font(.headline) + Text(selectedPath) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let saveAction, !readOnly { + Button(isSaving ? "Saving..." : "Save") { + saveAction() + } + .disabled(isSaving || secondBrainHash.isEmpty) + } + } + TextEditor(text: $selectedContent) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 260) + .disabled(readOnly) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary, lineWidth: 1) + ) + } + } + } + + private func refreshActiveTab() async { + switch tab { + case "knowledge": + await loadKnowledgePages() + case "second": + await loadSecondBrainSources() + default: + await loadMemoryFiles() + } + } + + private func loadMemoryFiles() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + memoryFiles = try await appState.caelWorkspaceAPIService.listMemoryFiles(connection: connection).files + statusMessage = "Loaded \(memoryFiles.count) memory files." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func readMemory(_ path: String, title: String) async { + guard let connection = appState.activeConnection else { return } + do { + let response = try await appState.caelWorkspaceAPIService.readMemoryFile(connection: connection, path: path) + selectedTitle = title + selectedPath = response.path ?? path + selectedContent = response.content ?? "" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func loadKnowledgePages() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + let response = try await appState.caelWorkspaceAPIService.listKnowledgePages(connection: connection) + knowledgePages = response.pages + if let root = response.knowledgeRoot?.nilIfBlank { + statusMessage = "Loaded \(knowledgePages.count) knowledge pages from \(root)." + } else { + statusMessage = "Loaded \(knowledgePages.count) knowledge pages." + } + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func readKnowledge(_ path: String, title: String) async { + guard let connection = appState.activeConnection else { return } + do { + let response = try await appState.caelWorkspaceAPIService.readKnowledgePage(connection: connection, path: path) + selectedTitle = response.page?.title ?? title + selectedPath = response.page?.path ?? path + selectedContent = response.content ?? "" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func runSearch() async { + guard let connection = appState.activeConnection else { return } + do { + if tab == "knowledge" { + knowledgeMatches = try await appState.caelWorkspaceAPIService.searchKnowledgePages(connection: connection, query: query).results + statusMessage = "Found \(knowledgeMatches.count) knowledge matches." + } else { + memoryMatches = try await appState.caelWorkspaceAPIService.searchMemoryFiles(connection: connection, query: query).results + statusMessage = "Found \(memoryMatches.count) memory matches." + } + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func loadSecondBrainSources() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + let response = try await appState.caelWorkspaceAPIService.listSecondBrainSources(connection: connection) + secondBrainSources = response.sources ?? [] + if secondBrainSourceID.isEmpty { + secondBrainSourceID = secondBrainSources.first(where: { $0.status == "available" })?.id ?? secondBrainSources.first?.id ?? "" + } + await listSecondBrainEntries() + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func listSecondBrainEntries() async { + guard let connection = appState.activeConnection, !secondBrainSourceID.isEmpty else { return } + do { + let response = try await appState.caelWorkspaceAPIService.listSecondBrainEntries( + connection: connection, + source: secondBrainSourceID, + path: secondBrainFolder + ) + secondBrainEntries = response.entries ?? [] + statusMessage = "Loaded \(secondBrainEntries.count) second-brain entries." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func readSecondBrain(_ path: String) async { + guard let connection = appState.activeConnection, !secondBrainSourceID.isEmpty else { return } + do { + let response = try await appState.caelWorkspaceAPIService.readSecondBrainFile( + connection: connection, + source: secondBrainSourceID, + path: path + ) + selectedTitle = path + selectedPath = response.path ?? path + selectedContent = response.content ?? "" + secondBrainHash = response.hash ?? "" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func dispatchSecondBrainWorkflow() async { + guard let connection = appState.activeConnection, !secondBrainSourceID.isEmpty else { return } + isDispatchingSecondBrainWorkflow = true + defer { isDispatchingSecondBrainWorkflow = false } + do { + let trimmedPath = selectedPath.trimmingCharacters(in: .whitespacesAndNewlines) + let hash = trimmedPath.isEmpty ? nil : secondBrainHash.nilIfBlank + let response = try await appState.caelWorkspaceAPIService.dispatchSecondBrainWorkflow( + connection: connection, + source: secondBrainSourceID, + path: trimmedPath.nilIfBlank, + operation: secondBrainDispatchOperation, + hash: hash + ) + secondBrainDispatchResponse = response + statusMessage = "Second-brain \(response.status ?? "dispatch") accepted for \(secondBrainDispatchOperation)." + } catch { + secondBrainDispatchResponse = nil + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func saveSecondBrain() async { + guard let connection = appState.activeConnection, !secondBrainSourceID.isEmpty, !selectedPath.isEmpty else { return } + isSaving = true + defer { isSaving = false } + do { + let response = try await appState.caelWorkspaceAPIService.writeSecondBrainFile( + connection: connection, + source: secondBrainSourceID, + path: selectedPath, + content: selectedContent, + expectedHash: secondBrainHash + ) + secondBrainHash = response.hash ?? secondBrainHash + statusMessage = "Saved second-brain file with hash guard." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + } + + private func secondBrainDispatchDetail(_ response: WorkspaceSecondBrainDispatchResponse) -> String { + let endpoint = response.n8n?.endpointLabel ?? "not configured" + let idempotencyKey = response.idempotencyKey ?? "no idempotency key returned" + return "Operation: \(response.operation ?? secondBrainDispatchOperation). Endpoint: \(endpoint). Idempotency: \(idempotencyKey)." + } + + private func parentPath(_ value: String) -> String { + guard let index = value.lastIndex(of: "/") else { return "" } + return String(value[.. String { + if size < 1024 { return "\(size) B" } + if size < 1024 * 1024 { return String(format: "%.1f KB", Double(size) / 1024) } + return String(format: "%.1f MB", Double(size) / (1024 * 1024)) + } + + private func preview(_ value: String, maxLength: Int) -> String { + let normalized = value.replacingOccurrences(of: "\n", with: " ").trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.count <= maxLength { return normalized } + let index = normalized.index(normalized.startIndex, offsetBy: maxLength) + return String(normalized[..: View { + let title: String + let emptyText: String + let content: () -> Content + + init(title: String, emptyText: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.emptyText = emptyText + self.content = content + } + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + .lineLimit(2) + content() + } + } + } +} + +private struct MirrorRow: View { + let title: String + let detail: String + var badge: String? + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title.capitalized) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let badge { + MirrorBadge(label: badge.capitalized, tint: tint) + } + } + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 3) + } +} + +private struct MirrorBadge: View { + let label: String + let tint: Color + + var body: some View { + Text(label) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(tint) + .background(tint.opacity(0.14), in: Capsule()) + } +} + +private extension String { + var nilIfBlank: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/HermesDesktop/Views/Connections/ConnectionEditorSheet.swift b/Sources/HermesDesktop/Views/Connections/ConnectionEditorSheet.swift index 17b07ed..65b2985 100644 --- a/Sources/HermesDesktop/Views/Connections/ConnectionEditorSheet.swift +++ b/Sources/HermesDesktop/Views/Connections/ConnectionEditorSheet.swift @@ -11,6 +11,7 @@ struct ConnectionEditorSheet: View { case port case hermesProfile case customHermesHome + case caelWorkspaceBaseURL } @State private var draft: ConnectionProfile @@ -96,6 +97,17 @@ struct ConnectionEditorSheet: View { .textFieldStyle(.roundedBorder) } + EditorField(label: "Cael Workspace URL") { + TextField(ConnectionProfile.defaultCaelWorkspaceBaseURL, text: caelWorkspaceBaseURLBinding) + .focused($focusedField, equals: .caelWorkspaceBaseURL) + .textFieldStyle(.roundedBorder) + } + + Text(L10n.string("Leave this empty for the default BigMac Workspace at `http://100.97.216.111:3077`. Set it only when this host should use a different Cael Workspace API root.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + DisclosureGroup( isExpanded: $showsCustomHermesHomeOptions ) { @@ -270,6 +282,14 @@ struct ConnectionEditorSheet: View { draft.customHermesHomePath = newValue } } + + private var caelWorkspaceBaseURLBinding: Binding { + Binding { + draft.caelWorkspaceBaseURL ?? "" + } set: { newValue in + draft.caelWorkspaceBaseURL = newValue + } + } } private struct EditorField: View { diff --git a/Sources/HermesDesktop/Views/CronJobs/CronJobsView.swift b/Sources/HermesDesktop/Views/CronJobs/CronJobsView.swift index 6319067..4b23d70 100644 --- a/Sources/HermesDesktop/Views/CronJobs/CronJobsView.swift +++ b/Sources/HermesDesktop/Views/CronJobs/CronJobsView.swift @@ -331,6 +331,7 @@ private struct CronJobCardRow: View { if job.noAgent { HermesBadge(text: "Script", tint: .blue) + HermesBadge(text: "Native-only", tint: .purple) } if let model = job.displayModel { @@ -384,6 +385,8 @@ private struct CronJobCardRow: View { } private struct CronJobDetailView: View { + @EnvironmentObject private var appState: AppState + let job: CronJob? let operationInFlight: Bool let onEdit: () -> Void @@ -392,6 +395,11 @@ private struct CronJobDetailView: View { let onTogglePause: () -> Void let onDelete: () -> Void + @State private var outputsJobID: String? + @State private var outputs: [CronJobOutput] = [] + @State private var isLoadingOutputs = false + @State private var outputError: String? + private let metadataColumns = [ GridItem(.adaptive(minimum: 180), alignment: .topLeading) ] @@ -415,6 +423,8 @@ private struct CronJobDetailView: View { metadataPanel(job) + outputPanel(job) + if !job.skills.isEmpty { HermesSurfacePanel( title: "Skills", @@ -437,9 +447,15 @@ private struct CronJobDetailView: View { if job.noAgent { HermesSurfacePanel( title: "Script", - subtitle: "Script-only jobs run without waking an agent." + subtitle: "Script-only jobs use the desktop native scheduler path, not the shared web job runner." ) { VStack(alignment: .leading, spacing: 12) { + HermesLabeledValue( + label: "Contract", + value: "Desktop native CronBrowserService path; not part of shared /api/claude-jobs parity.", + isMonospaced: true + ) + HermesLabeledValue( label: "Script path", value: job.trimmedScript ?? L10n.string("No script configured"), @@ -484,6 +500,93 @@ private struct CronJobDetailView: View { .padding(.horizontal, 24) .padding(.vertical, 22) } + .task(id: job?.id) { + guard let job else { return } + await loadOutputs(for: job) + } + } + + private func outputPanel(_ job: CronJob) -> some View { + HermesSurfacePanel( + title: "Recent Output", + subtitle: "Latest captured output from the shared Workspace jobs API." + ) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Button(isLoadingOutputs ? "Loading..." : "Refresh Output") { + Task { await loadOutputs(for: job, force: true) } + } + .disabled(isLoadingOutputs) + + if isLoadingOutputs { + ProgressView() + .controlSize(.small) + } + + Spacer() + + if !outputs.isEmpty { + HermesBadge(text: "\(outputs.count) items", tint: .accentColor) + } + } + + if let outputError { + Text(outputError) + .foregroundStyle(.orange) + .font(.callout) + .textSelection(.enabled) + } else if outputs.isEmpty && !isLoadingOutputs { + ContentUnavailableView( + "No output captured", + systemImage: "doc.text.magnifyingglass", + description: Text("This job has no stored output yet, or the active gateway returned an empty output list.") + ) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + ForEach(outputs.prefix(5)) { output in + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(output.displayTitle) + .font(.subheadline.weight(.semibold)) + Spacer() + Text(ByteCountFormatter.string(fromByteCount: Int64(output.size), countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(output.timestamp) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text(output.previewContent) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + } + } + } + } + } + + private func loadOutputs(for job: CronJob, force: Bool = false) async { + guard let connection = appState.activeConnection else { return } + if !force, outputsJobID == job.id { return } + outputsJobID = job.id + isLoadingOutputs = true + outputError = nil + defer { isLoadingOutputs = false } + do { + outputs = try await appState.caelWorkspaceAPIService.loadWorkspaceCronJobOutputs( + connection: connection, + jobID: job.id, + limit: 10 + ) + } catch { + outputs = [] + outputError = error.localizedDescription + } } private func headerPanel(_ job: CronJob) -> some View { diff --git a/Sources/HermesDesktop/Views/Files/FilesView.swift b/Sources/HermesDesktop/Views/Files/FilesView.swift index 730e809..3a49e03 100644 --- a/Sources/HermesDesktop/Views/Files/FilesView.swift +++ b/Sources/HermesDesktop/Views/Files/FilesView.swift @@ -1,4 +1,6 @@ import SwiftUI +import AppKit +import UniformTypeIdentifiers struct FilesView: View { @EnvironmentObject private var appState: AppState @@ -21,6 +23,7 @@ struct FilesView: View { filesToolbar libraryPanel + toolArtifactsPanel } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, 20) @@ -33,6 +36,9 @@ struct FilesView: View { .task(id: selectedFileLoadTaskID) { await appState.loadSelectedWorkspaceFile() } + .task(id: appState.activeConnectionID) { + await appState.loadToolArtifacts(resetSelection: true) + } .sheet(isPresented: $showBrowserSheet) { WorkspaceFileBrowserSheet() .environmentObject(appState) @@ -110,6 +116,61 @@ struct FilesView: View { } } + private var toolArtifactsPanel: some View { + HermesSurfacePanel( + title: "Tool Artifacts", + subtitle: "Externalized tool output from Workspace sessions." + ) { + VStack(alignment: .leading, spacing: 10) { + HStack { + if appState.isLoadingToolArtifacts { + ProgressView() + .controlSize(.small) + } + + Spacer() + + Button { + Task { + await appState.loadToolArtifacts(resetSelection: false) + } + } label: { + Label(L10n.string("Refresh"), systemImage: "arrow.clockwise") + } + .controlSize(.small) + .disabled(appState.isLoadingToolArtifacts) + } + + if let errorMessage = appState.toolArtifactsError { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } else if appState.toolArtifacts.isEmpty { + Text(L10n.string("No tool artifacts surfaced.")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(appState.toolArtifacts.prefix(8)) { artifact in + ToolArtifactRow( + artifact: artifact, + isSelected: artifact.id == appState.selectedToolArtifactID + ) { + Task { + await appState.selectToolArtifact(artifact.id) + } + } + } + } + } + .frame(maxHeight: 220) + } + } + } + } + private func fileGroup(title: String, references: [WorkspaceFileReference]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(L10n.string(title)) @@ -265,31 +326,41 @@ struct FilesView: View { private var editorPane: some View { Group { if let selectedReference { - WorkspaceFileEditorPane( - reference: selectedReference, - document: currentDocument, - text: editorBinding, - onReload: { - if currentDocument?.isDirty == true { - showReloadDiscardAlert = true - } else { + VStack(alignment: .leading, spacing: 16) { + if let artifact = appState.selectedToolArtifactDetail { + ToolArtifactDetailPanel( + artifact: artifact, + isLoading: appState.isLoadingToolArtifactDetail + ) + } + + WorkspaceFileEditorPane( + reference: selectedReference, + document: currentDocument, + text: editorBinding, + onReload: { + if currentDocument?.isDirty == true { + showReloadDiscardAlert = true + } else { + Task { + await appState.loadWorkspaceFile(selectedReference, forceReload: true) + } + } + }, + onSave: { Task { - await appState.loadWorkspaceFile(selectedReference, forceReload: true) + await appState.saveWorkspaceFile(fileID: selectedReference.id) + } + }, + onRemove: selectedReference.bookmarkID.map { bookmarkID in + { + bookmarkPendingRemoval = bookmarkID + showRemoveBookmarkAlert = true } } - }, - onSave: { - Task { - await appState.saveWorkspaceFile(fileID: selectedReference.id) - } - }, - onRemove: selectedReference.bookmarkID.map { bookmarkID in - { - bookmarkPendingRemoval = bookmarkID - showRemoveBookmarkAlert = true - } - } - ) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ScrollView { HermesSurfacePanel { @@ -346,6 +417,115 @@ struct FilesView: View { } } +private struct ToolArtifactRow: View { + let artifact: ToolArtifactSummary + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: iconName) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(artifact.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(artifact.kind) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.10), in: Capsule()) + } + + Text(artifact.preview.isEmpty ? artifact.summary : artifact.preview) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.06)) + ) + } + .buttonStyle(.plain) + } + + private var iconName: String { + switch artifact.kind { + case "diff": + "plus.forwardslash.minus" + case "terminal_log": + "terminal" + case "file_read": + "doc.text.magnifyingglass" + case "skill_doc": + "puzzlepiece" + default: + "doc.text" + } + } +} + +private struct ToolArtifactDetailPanel: View { + let artifact: ToolArtifactDetail + let isLoading: Bool + + var body: some View { + HermesSurfacePanel( + title: artifact.title, + subtitle: "\(artifact.kind) / \(artifact.sessionId)" + ) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Text(ByteCountFormatter.string(fromByteCount: Int64(artifact.contentSize), countStyle: .file)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(DateFormatters.relativeFormatter().localizedString(for: artifact.createdDate, relativeTo: .now)) + .font(.caption) + .foregroundStyle(.secondary) + + if isLoading { + ProgressView() + .controlSize(.small) + } + } + + Text(artifact.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + ScrollView { + Text(artifact.content.isEmpty ? artifact.preview : artifact.content) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + .frame(maxHeight: 180) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + } + .padding(.horizontal, 24) + .padding(.top, 22) + } +} + private struct WorkspaceFileCardRow: View { let reference: WorkspaceFileReference let subtitle: String? @@ -420,6 +600,7 @@ private struct WorkspaceFileEditorPane: View { let onReload: () -> Void let onSave: () -> Void let onRemove: (() -> Void)? + @State private var showSaveReview = false private var isDirty: Bool { document?.isDirty == true @@ -452,6 +633,20 @@ private struct WorkspaceFileEditorPane: View { .padding(.horizontal, 24) .padding(.vertical, 22) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .sheet(isPresented: $showSaveReview) { + if let document { + WorkspaceFileSaveReviewSheet( + reference: reference, + document: document, + editedText: text, + onCancel: { showSaveReview = false }, + onConfirm: { + showSaveReview = false + onSave() + } + ) + } + } } private var headerPanel: some View { @@ -506,7 +701,9 @@ private struct WorkspaceFileEditorPane: View { Button(L10n.string("Reload"), action: onReload) .disabled(isLoading) - Button(L10n.string("Save"), action: onSave) + Button(L10n.string("Save")) { + showSaveReview = true + } .buttonStyle(.borderedProminent) .disabled(!isDirty || isLoading || !hasLoaded) @@ -558,11 +755,265 @@ private struct WorkspaceFileEditorPane: View { } } +private struct WorkspaceFileSaveReviewSheet: View { + @Environment(\.dismiss) private var dismiss + let reference: WorkspaceFileReference + let document: FileEditorDocument + let editedText: String + let onCancel: () -> Void + let onConfirm: () -> Void + + private var summary: WorkspaceFileChangeSummary { + WorkspaceFileChangeSummary(original: document.originalContent, edited: editedText) + } + + private var rows: [WorkspaceFileDiffRow] { + WorkspaceFileDiffRow.rows(original: document.originalContent, edited: editedText) + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("Review Save")) + .font(.title2.weight(.semibold)) + + Text(reference.remotePath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Spacer(minLength: 12) + + HermesBadge(text: "Unsaved", tint: .orange) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 10)], spacing: 10) { + WorkspaceFileChangeMetric(label: "Changed", value: "\(summary.changedLineCount)") + WorkspaceFileChangeMetric(label: "Added", value: "\(summary.addedLineCount)") + WorkspaceFileChangeMetric(label: "Removed", value: "\(summary.removedLineCount)") + WorkspaceFileChangeMetric(label: "Characters", value: summary.characterDeltaText) + } + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Server guard")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Text(document.remoteContentHash.map { L10n.string("Save will use the last loaded content hash: %@", $0) } ?? L10n.string("Reload this file before saving so the server can detect remote changes.")) + .font(.caption) + .foregroundStyle(document.remoteContentHash == nil ? .orange : .secondary) + .textSelection(.enabled) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(L10n.string("Changed lines")) + .font(.headline) + + Spacer() + + if rows.count > WorkspaceFileDiffRow.previewLimit { + Text(L10n.string("Showing %@ of %@", "\(WorkspaceFileDiffRow.previewLimit)", "\(rows.count)")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + if rows.isEmpty { + Text(L10n.string("No line-level changes detected.")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(rows.prefix(WorkspaceFileDiffRow.previewLimit)) { row in + WorkspaceFileDiffRowView(row: row) + } + } + } + .padding(10) + } + .frame(minHeight: 180, maxHeight: 300) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + HStack { + Button(L10n.string("Cancel")) { + dismiss() + onCancel() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(L10n.string("Save to Workspace")) { + dismiss() + onConfirm() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(document.remoteContentHash == nil) + } + } + .padding(22) + .frame(width: 680) + .frame(minHeight: 560) + } +} + +private struct WorkspaceFileChangeSummary { + let changedLineCount: Int + let addedLineCount: Int + let removedLineCount: Int + let originalCharacterCount: Int + let editedCharacterCount: Int + + init(original: String, edited: String) { + let originalLines = WorkspaceFileDiffRow.lines(original) + let editedLines = WorkspaceFileDiffRow.lines(edited) + let sharedCount = min(originalLines.count, editedLines.count) + let changed = (0.. 0 { return "+\(delta)" } + return "\(delta)" + } +} + +private struct WorkspaceFileChangeMetric: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(L10n.string(label)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Text(value) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct WorkspaceFileDiffRow: Identifiable { + enum Kind { + case added + case removed + } + + static let previewLimit = 120 + let id: String + let kind: Kind + let lineNumber: Int + let text: String + + static func rows(original: String, edited: String) -> [WorkspaceFileDiffRow] { + let originalLines = lines(original) + let editedLines = lines(edited) + let maxCount = max(originalLines.count, editedLines.count) + var rows: [WorkspaceFileDiffRow] = [] + + for index in 0.. [String] { + value.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + } +} + +private struct WorkspaceFileDiffRowView: View { + let row: WorkspaceFileDiffRow + + private var marker: String { + switch row.kind { + case .added: return "+" + case .removed: return "-" + } + } + + private var tint: Color { + switch row.kind { + case .added: return .green + case .removed: return .red + } + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text(marker) + .font(.system(.caption, design: .monospaced).weight(.bold)) + .foregroundStyle(tint) + .frame(width: 12) + + Text("\(row.lineNumber)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .trailing) + + Text(row.text.isEmpty ? " " : row.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } +} + + private struct WorkspaceFileBrowserSheet: View { @EnvironmentObject private var appState: AppState @Environment(\.dismiss) private var dismiss @State private var pathText = "" @State private var didLoadInitialDirectory = false + @State private var pathActionDraft: WorkspaceFilePathActionDraft? + @State private var pendingDeleteEntry: RemoteDirectoryEntry? + @State private var showDeletePathAlert = false + @State private var showUploadImporter = false var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -621,6 +1072,25 @@ private struct WorkspaceFileBrowserSheet: View { } } + Button { + beginCreateFolder() + } label: { + Label(L10n.string("New Folder"), systemImage: "folder.badge.plus") + } + .disabled(appState.workspaceFileBrowserListing == nil) + + Button { + showUploadImporter = true + } label: { + Label(L10n.string("Upload"), systemImage: "square.and.arrow.up") + } + .disabled(appState.workspaceFileBrowserListing == nil || appState.isUploadingWorkspaceFile) + + if appState.isUploadingWorkspaceFile { + ProgressView() + .controlSize(.small) + } + Spacer() } .controlSize(.small) @@ -634,7 +1104,17 @@ private struct WorkspaceFileBrowserSheet: View { .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } + if let notice = appState.workspaceFileBrowserNotice { + Text(notice) + .font(.subheadline) + .foregroundStyle(.green) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + browserContent + previewPanel HStack { if let listing = appState.workspaceFileBrowserListing { @@ -660,7 +1140,41 @@ private struct WorkspaceFileBrowserSheet: View { } } .padding(22) - .frame(width: 760, height: 560) + .frame(width: 820, height: 700) + .fileImporter( + isPresented: $showUploadImporter, + allowedContentTypes: [.item], + allowsMultipleSelection: false + ) { result in + handleUploadSelection(result) + } + .sheet(item: $pathActionDraft) { draft in + WorkspaceFilePathActionSheet(draft: draft) { submittedPath in + switch draft.kind { + case .newFolder: + Task { + await appState.createWorkspaceDirectory(path: submittedPath) + } + case .rename(let sourcePath): + Task { + await appState.renameWorkspacePath(from: sourcePath, to: submittedPath) + } + } + } + } + .alert(L10n.string("Delete this path?"), isPresented: $showDeletePathAlert, presenting: pendingDeleteEntry) { entry in + Button(L10n.string("Delete"), role: .destructive) { + Task { + await appState.deleteWorkspacePath(path: entry.displayPath) + pendingDeleteEntry = nil + } + } + Button(L10n.string("Cancel"), role: .cancel) { + pendingDeleteEntry = nil + } + } message: { entry in + Text(L10n.string("This removes %@ from the active workspace.", entry.name)) + } .task { guard !didLoadInitialDirectory else { return } didLoadInitialDirectory = true @@ -744,6 +1258,37 @@ private struct WorkspaceFileBrowserSheet: View { } .controlSize(.small) } + + Menu { + if entry.kind == .file { + Button { + Task { + await appState.previewWorkspacePath(entry.displayPath) + } + } label: { + Label(L10n.string("Preview"), systemImage: "eye") + } + } + + Button { + beginRename(entry) + } label: { + Label(L10n.string("Rename"), systemImage: "pencil") + } + + Button(role: .destructive) { + pendingDeleteEntry = entry + showDeletePathAlert = true + } label: { + Label(L10n.string("Delete"), systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + .imageScale(.medium) + .frame(width: 24, height: 24) + } + .menuStyle(.borderlessButton) + .controlSize(.small) } .contentShape(Rectangle()) .onTapGesture(count: 2) { @@ -755,6 +1300,57 @@ private struct WorkspaceFileBrowserSheet: View { } } + @ViewBuilder + private var previewPanel: some View { + if appState.isLoadingWorkspacePreview || appState.workspacePreviewFile != nil || appState.workspacePreviewError != nil { + HermesSurfacePanel( + title: "Preview", + subtitle: appState.workspacePreviewFile?.path ?? "Workspace preview endpoint" + ) { + if appState.isLoadingWorkspacePreview { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(L10n.string("Loading preview...")) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if let errorMessage = appState.workspacePreviewError { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else if let preview = appState.workspacePreviewFile { + WorkspacePreviewPanel(preview: preview) + } + } + .frame(maxHeight: 190) + } + } + + private func beginCreateFolder() { + let parentPath = appState.workspaceFileBrowserListing?.displayPath ?? pathText + pathActionDraft = WorkspaceFilePathActionDraft( + kind: .newFolder, + title: L10n.string("Create Folder"), + prompt: L10n.string("Enter the full workspace path for the new folder."), + initialPath: appendingPathComponent("New Folder", to: parentPath), + submitTitle: L10n.string("Create") + ) + } + + private func beginRename(_ entry: RemoteDirectoryEntry) { + pathActionDraft = WorkspaceFilePathActionDraft( + kind: .rename(sourcePath: entry.displayPath), + title: L10n.string("Rename Path"), + prompt: L10n.string("Enter the new full workspace path."), + initialPath: entry.displayPath, + submitTitle: L10n.string("Rename") + ) + } + private func browse(_ path: String) { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -778,6 +1374,22 @@ private struct WorkspaceFileBrowserSheet: View { appState.addWorkspaceFileBookmark(remotePath: entry.displayPath, selectAfterAdd: false) } + private func handleUploadSelection(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + Task { + await appState.uploadWorkspaceFile(localFileURL: url, to: currentUploadTargetPath) + } + case .failure(let error): + appState.workspaceFileBrowserError = error.localizedDescription + } + } + + private var currentUploadTargetPath: String { + appState.workspaceFileBrowserListing?.displayPath ?? pathText + } + private func isBookmarked(_ entry: RemoteDirectoryEntry) -> Bool { appState.bookmarkedWorkspaceFileReferences.contains { reference in reference.remotePath == entry.displayPath @@ -829,4 +1441,124 @@ private struct WorkspaceFileBrowserSheet: View { return parts.joined(separator: " / ") } + + private func appendingPathComponent(_ component: String, to parent: String) -> String { + let trimmedParent = parent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedParent.isEmpty else { return component } + return trimmedParent.hasSuffix("/") ? "\(trimmedParent)\(component)" : "\(trimmedParent)/\(component)" + } +} + +private struct WorkspacePreviewPanel: View { + let preview: WorkspacePreviewFile + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(preview.kind) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.10), in: Capsule()) + + Text(preview.mime) + .font(.caption) + .foregroundStyle(.secondary) + + Text(ByteCountFormatter.string(fromByteCount: preview.size, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + + if preview.isText { + ScrollView { + Text(preview.content ?? "") + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else if preview.isImage, + let encoded = preview.contentBase64, + let data = Data(base64Encoded: encoded), + let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: 110, alignment: .leading) + } else { + Text(L10n.string("Binary preview metadata loaded.")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private enum WorkspaceFilePathActionKind { + case newFolder + case rename(sourcePath: String) +} + +private struct WorkspaceFilePathActionDraft: Identifiable { + let id = UUID() + let kind: WorkspaceFilePathActionKind + let title: String + let prompt: String + let initialPath: String + let submitTitle: String +} + +private struct WorkspaceFilePathActionSheet: View { + @Environment(\.dismiss) private var dismiss + let draft: WorkspaceFilePathActionDraft + let onSubmit: (String) -> Void + @State private var pathText: String + + init(draft: WorkspaceFilePathActionDraft, onSubmit: @escaping (String) -> Void) { + self.draft = draft + self.onSubmit = onSubmit + _pathText = State(initialValue: draft.initialPath) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(draft.title) + .font(.title3.weight(.semibold)) + + Text(draft.prompt) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField(L10n.string("Workspace path"), text: $pathText) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .onSubmit(submit) + + HStack { + Spacer() + Button(L10n.string("Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(draft.submitTitle) { + submit() + } + .keyboardShortcut(.defaultAction) + .disabled(pathText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 520) + } + + private func submit() { + let trimmed = pathText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSubmit(trimmed) + dismiss() + } } diff --git a/Sources/HermesDesktop/Views/Homebase/CaelProviderLimitsWebPanel.swift b/Sources/HermesDesktop/Views/Homebase/CaelProviderLimitsWebPanel.swift new file mode 100644 index 0000000..cbd74ec --- /dev/null +++ b/Sources/HermesDesktop/Views/Homebase/CaelProviderLimitsWebPanel.swift @@ -0,0 +1,735 @@ +import SwiftUI + +struct CaelProviderLimitsWebPanel: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + HermesSurfacePanel( + title: "Cael Model Usage Limits", + subtitle: "Active Cael model providers are shown first; external monitors remain secondary." + ) { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Label("Shared Usage Snapshot", systemImage: "gauge.with.dots.needle.bottom.50percent") + .font(.subheadline.weight(.semibold)) + Spacer() + HermesRefreshButton(isRefreshing: appState.isRefreshingCaelProviderUsage) { + Task { await appState.refreshCaelProviderUsage() } + } + Link(destination: usageURL) { + Label("Open Web Usage", systemImage: "safari") + } + .buttonStyle(.bordered) + } + + content + } + } + .task(id: appState.activeConnectionID) { + await appState.loadCaelProviderUsage() + } + } + + private var usageURL: URL { + appState.activeConnection?.caelWorkspaceURL(path: "/usage") ?? ConnectionProfile().caelWorkspaceURL(path: "/usage") + } + + private func sortedProviders(_ providers: [CaelProviderUsageCard]) -> [CaelProviderUsageCard] { + providers.sorted { left, right in + let leftRank = providerRank(left) + let rightRank = providerRank(right) + if leftRank != rightRank { return leftRank < rightRank } + return left.label.localizedCaseInsensitiveCompare(right.label) == .orderedAscending + } + } + + private func providerRank(_ provider: CaelProviderUsageCard) -> Int { + if provider.caelDefault == true { return 0 } + if provider.caelConfigured == true { return 1 } + if provider.monitorKind == "cael" { return 2 } + return 3 + } + + @ViewBuilder + private var content: some View { + if appState.isLoadingCaelProviderUsage, + appState.caelProviderUsageLimits == nil { + HermesLoadingState(label: "Loading provider usage limits…", minHeight: 220) + } else if let error = appState.caelProviderUsageError, + appState.caelProviderUsageLimits == nil { + ContentUnavailableView( + "Provider limits unavailable", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .frame(maxWidth: .infinity, minHeight: 220) + } else if let limits = appState.caelProviderUsageLimits { + VStack(alignment: .leading, spacing: 14) { + CaelModelRosterStrip(providers: sortedProviders(limits.providers)) + CaelModelConfigControl() + CaelModelRuntimeDrilldown() + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(sortedProviders(limits.providers)) { provider in + CaelProviderUsageNativeCard(provider: provider) + } + } + } + } + } +} + + +private struct CaelModelRosterStrip: View { + let providers: [CaelProviderUsageCard] + + private var caelProviders: [CaelProviderUsageCard] { + providers.filter { $0.caelConfigured == true || $0.monitorKind == "cael" } + } + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Label("Active Cael model roster", systemImage: "cpu") + .font(.subheadline.weight(.semibold)) + Spacer() + HermesBadge(text: "\(caelProviders.count) Cael monitors", tint: .accentColor) + } + + if caelProviders.isEmpty { + Text("No Cael-configured model providers were reported by /api/usage/limits.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + FlowLayout(spacing: 6) { + ForEach(caelProviders) { provider in + Text(rosterLabel(for: provider)) + .font(.caption.weight(provider.caelDefault == true ? .semibold : .regular)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background((provider.caelDefault == true ? Color.accentColor : Color.secondary).opacity(0.12), in: Capsule()) + } + } + } + } + } + } + + private func rosterLabel(for provider: CaelProviderUsageCard) -> String { + let model = provider.caelModel ?? provider.caelModels?.first ?? "configured" + if provider.caelDefault == true { + return "Default: \(provider.label) / \(model)" + } + return "\(provider.label) / \(model)" + } +} + + +private struct CaelModelConfigControl: View { + @EnvironmentObject private var appState: AppState + @State private var config: WorkspaceHermesConfigResponse? + @State private var catalog: WorkspaceModelCatalogResponse? + @State private var providerID = "" + @State private var modelID = "" + @State private var notice: String? + @State private var isLoading = false + @State private var isSaving = false + @State private var useManualEntry = false + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Label("Active Cael model config", systemImage: "slider.horizontal.3") + .font(.subheadline.weight(.semibold)) + Spacer() + Button(isLoading ? "Loading..." : "Reload") { + Task { await loadConfig() } + } + .disabled(isLoading || isSaving) + } + + Text(currentConfigLabel) + .font(.caption) + .foregroundStyle(.secondary) + + if useManualEntry || providerOptions.isEmpty { + HStack(spacing: 8) { + TextField("provider", text: $providerID) + .textFieldStyle(.roundedBorder) + TextField("model", text: $modelID) + .textFieldStyle(.roundedBorder) + applyButton + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Picker("Provider", selection: $providerID) { + ForEach(providerOptions, id: \.self) { provider in + Text(providerDisplayName(provider)).tag(provider) + } + } + .labelsHidden() + .frame(maxWidth: 220) + .onChange(of: providerID) { _, _ in + alignSelectedModelWithProvider() + } + + Picker("Model", selection: $modelID) { + ForEach(modelOptions, id: \.self) { model in + Text(modelDisplayName(model)).tag(model) + } + } + .labelsHidden() + .frame(minWidth: 260, maxWidth: .infinity) + + applyButton + } + + Text(catalogLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Toggle("Manual provider/model override", isOn: $useManualEntry) + .font(.caption) + .toggleStyle(.checkbox) + + if let notice { + Text(notice) + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let providers = config?.providers?.filter({ $0.configured == true || $0.isDefault == true }), !providers.isEmpty { + FlowLayout(spacing: 6) { + ForEach(providers) { provider in + Button(providerChipLabel(provider)) { + providerID = provider.id + modelID = provider.isDefault == true + ? (config?.activeModel ?? provider.models?.first?.id ?? modelID) + : (provider.models?.first?.id ?? modelID) + } + .buttonStyle(.plain) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background((provider.isDefault == true ? Color.accentColor : Color.secondary).opacity(0.12), in: Capsule()) + } + } + } + } + } + .task(id: appState.activeConnectionID) { + await loadConfig() + } + } + + private var applyButton: some View { + Button(isSaving ? "Applying..." : "Apply") { + Task { await applyModelConfig() } + } + .disabled(isSaving || providerID.nilIfBlank == nil || modelID.nilIfBlank == nil) + } + + private var currentConfigLabel: String { + let provider = config?.activeProvider?.nilIfBlank ?? "unknown provider" + let model = config?.activeModel?.nilIfBlank ?? "unknown model" + return "Shared active model: \(provider) / \(model). The picker is hydrated from /api/models and applies through /api/hermes-config." + } + + private var catalogModels: [WorkspaceModelCatalogEntry] { + catalog?.catalogModels ?? [] + } + + private var providerOptions: [String] { + let catalogProviders = catalogModels.compactMap { $0.provider?.nilIfBlank } + let configProviders = config?.providers?.compactMap { provider -> String? in + if provider.isDefault == true || provider.configured == true || provider.models?.isEmpty == false { + return provider.id.nilIfBlank + } + return nil + } ?? [] + let configuredProviders = catalog?.configuredProviders?.compactMap(\.nilIfBlank) ?? [] + let activeProvider = (config?.activeProvider)?.nilIfBlank.map { [$0] } ?? [] + return Array(Set(catalogProviders + configProviders + configuredProviders + activeProvider)).sorted() + } + + private var modelOptions: [String] { + guard let provider = providerID.nilIfBlank else { return [] } + let catalogMatches = catalogModels + .filter { ($0.provider?.nilIfBlank ?? "unknown") == provider } + .compactMap { $0.id.nilIfBlank } + let configMatches = config?.providers? + .first(where: { $0.id == provider })? + .models? + .compactMap { $0.id.nilIfBlank } ?? [] + let activeModel = config?.activeProvider == provider ? ((config?.activeModel)?.nilIfBlank.map { [$0] } ?? []) : [] + return Array(Set(catalogMatches + configMatches + activeModel)).sorted() + } + + private var catalogLabel: String { + let count = catalogModels.count + let source = catalog?.source?.nilIfBlank ?? "unknown source" + let providers = providerOptions.count + return "\(count) models across \(providers) providers from \(source). Raw credentials stay server-side." + } + + private func providerChipLabel(_ provider: WorkspaceHermesProviderState) -> String { + let name = provider.name.nilIfBlank ?? provider.id + if provider.isDefault == true { return "Default: \(name)" } + return name + } + + private func providerDisplayName(_ provider: String) -> String { + let name = config?.providers?.first(where: { $0.id == provider })?.name.nilIfBlank ?? provider + if provider == config?.activeProvider { return "Default: \(name)" } + return name + } + + private func modelDisplayName(_ model: String) -> String { + catalogModels.first(where: { $0.id == model })?.name?.nilIfBlank ?? model + } + + private func alignSelectedModelWithProvider() { + if !modelOptions.contains(modelID), let first = modelOptions.first { + modelID = first + } + } + + private func loadConfig() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + do { + let next = try await appState.caelWorkspaceAPIService.loadHermesConfig(connection: connection) + config = next + providerID = next.activeProvider?.nilIfBlank ?? providerID + modelID = next.activeModel?.nilIfBlank ?? modelID + notice = next.ok == false ? (next.error?.nilIfBlank ?? "Hermes config is unavailable.") : nil + } catch { + notice = "Unable to load Hermes config: \(error.localizedDescription)" + } + do { + let nextCatalog = try await appState.caelWorkspaceAPIService.loadWorkspaceModels(connection: connection) + catalog = nextCatalog + if providerID.nilIfBlank == nil { + providerID = providerOptions.first ?? "" + } + alignSelectedModelWithProvider() + if nextCatalog.ok == false { + notice = nextCatalog.error?.nilIfBlank ?? "Model catalog is unavailable." + } + } catch { + catalog = nil + useManualEntry = true + if notice == nil { + notice = "Unable to load model catalog: \(error.localizedDescription)" + } + } + } + + private func applyModelConfig() async { + guard let connection = appState.activeConnection, + let provider = providerID.nilIfBlank, + let model = modelID.nilIfBlank else { return } + isSaving = true + defer { isSaving = false } + do { + let result = try await appState.caelWorkspaceAPIService.setDefaultHermesModel( + connection: connection, + providerID: provider, + modelID: model + ) + notice = result.message?.nilIfBlank ?? "Default model updated." + await loadConfig() + await appState.loadCaelProviderUsage(forceRefresh: true) + } catch { + notice = "Unable to update default model: \(error.localizedDescription)" + } + } +} + +private struct CaelModelRuntimeDrilldown: View { + @EnvironmentObject private var appState: AppState + @State private var modelInfo: WorkspaceModelInfoResponse? + @State private var contextUsage: WorkspaceContextUsageResponse? + @State private var notice: String? + @State private var isLoading = false + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Label("Runtime model + context", systemImage: "chart.bar.doc.horizontal") + .font(.subheadline.weight(.semibold)) + Spacer() + Button(isLoading ? "Loading..." : "Refresh") { + Task { await loadRuntimeDetails() } + } + .disabled(isLoading) + } + + Text("Active model capabilities and current context usage.") + .font(.caption) + .foregroundStyle(.secondary) + + if isLoading, modelInfo == nil, contextUsage == nil { + ProgressView() + .controlSize(.small) + } + + if let notice { + Text(notice) + .font(.caption2) + .foregroundStyle(.secondary) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 10, alignment: .top)], spacing: 10) { + metricCard( + title: "Active runtime", + value: activeRuntimeLabel, + detail: modelInfoDetail + ) + metricCard( + title: "Context window", + value: formatTokens(modelInfo?.effectiveContextLength ?? contextUsage?.maxTokens), + detail: contextWindowDetail + ) + metricCard( + title: "Current context", + value: contextPercentLabel, + detail: contextUsageDetail + ) + } + + if let contextUsage { + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: max(0, min(100, contextUsage.contextPercent)), total: 100) + .tint(contextUsage.contextPercent > 85 ? .orange : .green) + HStack { + Text("Conversation \(formatTokens(contextUsage.conversationTokens))") + Spacer() + Text("Static \(formatTokens(contextUsage.staticTokens))") + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + if let capabilities = capabilityRows, !capabilities.isEmpty { + FlowLayout(spacing: 6) { + ForEach(capabilities, id: \.self) { capability in + Text(capability) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.10), in: Capsule()) + } + } + } + } + } + .task(id: taskKey) { + await loadRuntimeDetails() + } + } + + private var taskKey: String { + "\(appState.activeConnectionID?.uuidString ?? "none"):\(appState.selectedSessionID ?? "global")" + } + + private var activeRuntimeLabel: String { + let provider = modelInfo?.provider?.nilIfBlank ?? "unknown" + let model = modelInfo?.model?.nilIfBlank ?? contextUsage?.model.nilIfBlank ?? "unknown" + return "\(provider) / \(model)" + } + + private var modelInfoDetail: String { + let mode = modelInfo?.mode?.nilIfBlank ?? "unknown mode" + let gatewayMode = modelInfo?.gatewayMode?.nilIfBlank ?? "unknown gateway" + let switching: String + switch modelInfo?.supportsRuntimeSwitching { + case true: + switching = "runtime switching available" + case false: + switching = "runtime switching unavailable" + case nil: + switching = "runtime switching unknown" + } + return "\(mode), \(gatewayMode), \(switching)" + } + + private var contextWindowDetail: String { + let auto = formatTokens(modelInfo?.autoContextLength) + let configured = formatTokens(modelInfo?.configContextLength) + return "Auto \(auto); configured \(configured)" + } + + private var contextPercentLabel: String { + guard let contextUsage else { return "unknown" } + return "\(Int(contextUsage.contextPercent.rounded()))%" + } + + private var contextUsageDetail: String { + guard let contextUsage else { return "No context snapshot loaded." } + let session = appState.selectedSessionID?.nilIfBlank ?? "global" + return "\(formatTokens(contextUsage.usedTokens)) of \(formatTokens(contextUsage.maxTokens)) in \(session)" + } + + private var capabilityRows: [String]? { + guard let capabilities = modelInfo?.capabilities else { return nil } + return capabilities + .sorted { $0.key < $1.key } + .prefix(8) + .map { key, value in + "\(key): \(value.stringValue ?? value.displayString)" + } + } + + private func metricCard(title: String, value: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + Text(value) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func loadRuntimeDetails() async { + guard let connection = appState.activeConnection else { return } + isLoading = true + defer { isLoading = false } + async let nextModelInfo: WorkspaceModelInfoResponse? = try? appState.caelWorkspaceAPIService.loadWorkspaceModelInfo(connection: connection) + async let nextContextUsage: WorkspaceContextUsageResponse? = try? appState.caelWorkspaceAPIService.loadWorkspaceContextUsage( + connection: connection, + sessionID: appState.selectedSessionID + ) + let loadedModelInfo = await nextModelInfo + let loadedContextUsage = await nextContextUsage + modelInfo = loadedModelInfo + contextUsage = loadedContextUsage + if loadedModelInfo == nil && loadedContextUsage == nil { + notice = "Unable to load runtime model or context usage." + } else if loadedModelInfo?.error?.nilIfBlank != nil { + notice = loadedModelInfo?.error + } else if loadedContextUsage?.ok == false { + notice = loadedContextUsage?.error?.nilIfBlank ?? "Context usage is unavailable." + } else { + notice = nil + } + } + + private func formatTokens(_ value: Int?) -> String { + guard let value else { return "unknown" } + return value.formatted(.number.notation(.compactName)) + } +} + +private struct CaelProviderUsageNativeCard: View { + let provider: CaelProviderUsageCard + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(provider.label) + .font(.headline) + if let plan = provider.plan { + Text(plan) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + CaelProviderBadge(label: badgeLabel, tint: badgeTint) + } + + if let message = provider.message { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + ForEach(displayRows) { row in + CaelProviderUsageRow(row: row) + } + + FlowLayout(spacing: 6) { + if provider.caelDefault == true { + CaelProviderBadge(label: "Cael default", tint: .accentColor) + } else if provider.caelConfigured == true || provider.monitorKind == "cael" { + CaelProviderBadge(label: "Cael model", tint: .blue) + } + + if let caelModel = provider.caelModel { + Text(caelModel) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.10), in: Capsule()) + } + + ForEach(provider.badges) { badge in + Text("\(badge.label): \(badge.value)") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.10), in: Capsule()) + } + } + + if let models = provider.caelModels, models.count > 1 { + Text("Cael models: \(models.joined(separator: ", "))") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Text(provider.source) + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var displayRows: [CaelUsageWindow] { + Array(provider.usageRows.prefix(3)) + } + + private var badgeLabel: String { + switch provider.confidence { + case "live": "Live" + case "configured": "Configured" + case "missing": "Setup needed" + case "error": "Error" + default: provider.status.capitalized + } + } + + private var badgeTint: Color { + switch provider.confidence { + case "live": .green + case "configured": .blue + case "missing": .orange + case "error": .red + default: .secondary + } + } +} + +private struct CaelProviderUsageRow: View { + let row: CaelUsageWindow + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(row.label) + .font(.caption.weight(.semibold)) + Spacer() + Text("\(format(row.used, unit: row.unit)) / \(format(row.limit, unit: row.unit))") + .font(.caption) + .foregroundStyle(.secondary) + } + + ProgressView(value: max(0, min(100, row.usedPercent)), total: 100) + .tint(row.usedPercent > 85 ? .orange : .green) + + if let resetsAt = row.resetsAt { + Text("Resets \(resetsAt)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private func format(_ value: Double, unit: String) -> String { + switch unit { + case "dollars": + value.formatted(.currency(code: "USD").precision(.fractionLength(0...2))) + case "percent": + "\(Int(value.rounded()))%" + case "tokens": + value.formatted(.number.notation(.compactName)) + default: + value.formatted(.number.notation(.compactName)) + } + } +} + +private struct CaelProviderBadge: View { + let label: String + let tint: Color + + var body: some View { + Text(label) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.16), in: Capsule()) + .foregroundStyle(tint) + } +} + +private struct FlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let width = proposal.width ?? 0 + var position = CGPoint.zero + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if position.x > 0, position.x + size.width > width { + position.x = 0 + position.y += rowHeight + spacing + rowHeight = 0 + } + position.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + + return CGSize(width: width, height: position.y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + var position = CGPoint(x: bounds.minX, y: bounds.minY) + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if position.x > bounds.minX, position.x + size.width > bounds.maxX { + position.x = bounds.minX + position.y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: position, proposal: ProposedViewSize(size)) + position.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/HermesDesktop/Views/Homebase/CaelWorkspaceWebView.swift b/Sources/HermesDesktop/Views/Homebase/CaelWorkspaceWebView.swift new file mode 100644 index 0000000..f4f657d --- /dev/null +++ b/Sources/HermesDesktop/Views/Homebase/CaelWorkspaceWebView.swift @@ -0,0 +1,1049 @@ +import SwiftUI + +struct CaelWorkspaceWebView: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + HermesPageContainer(width: .analytics) { + VStack(alignment: .leading, spacing: 24) { + HermesPageHeader( + title: "Cael Homebase", + subtitle: "Native cockpit for the BigMac workspace runtime. Web Workspace stays available as a fallback, but this surface is rendered by the desktop app.", + accessory: { + HStack(spacing: 10) { + HermesRefreshButton(isRefreshing: appState.isRefreshingCaelWorkspace) { + Task { await appState.refreshCaelWorkspace() } + } + + Link(destination: workspaceURL) { + Label("Open Web Workspace", systemImage: "safari") + } + .buttonStyle(.bordered) + } + } + ) + + content + } + } + .task(id: appState.activeConnectionID) { + await appState.loadCaelWorkspace() + } + } + + private var workspaceURL: URL { + appState.activeConnection?.caelWorkspaceURL(path: "/cael-home") ?? ConnectionProfile().caelWorkspaceURL(path: "/cael-home") + } + + @ViewBuilder + private var content: some View { + if appState.isLoadingCaelWorkspace, + appState.caelWorkspaceStatus == nil, + appState.caelIntegrationStatus == nil, + appState.caelCommandCenterSummary == nil, + appState.caelCommandCenterSections == nil { + HermesSurfacePanel { + HermesLoadingState(label: "Loading Cael workspace…", minHeight: 320) + } + } else if let error = appState.caelWorkspaceError, + appState.caelWorkspaceStatus == nil, + appState.caelCommandCenterSummary == nil, + appState.caelCommandCenterSections == nil { + HermesSurfacePanel { + ContentUnavailableView( + "Unable to load Cael workspace", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .frame(maxWidth: .infinity, minHeight: 320) + } + } else { + if let cacheNotice = appState.caelCommandCenterCacheNotice { + HermesInsetSurface { + CaelCommandCenterRow( + title: "Last-known snapshot", + detail: cacheNotice, + badge: "Cached", + tint: .orange + ) + } + } + + if let summary = appState.caelCommandCenterSummary { + commandCenterSnapshot(summary, warnings: appState.caelCommandCenterWarnings) + } + + if let sections = appState.caelCommandCenterSections { + commandCenterSectionsSnapshot(sections) + } + + if let status = appState.caelWorkspaceStatus { + statusOverview(status) + privateAccessMeshPanel(status) + fastLanesPanel(status) + contextBoundariesPanel(status) + } + + n8nGovernancePanel(appState.caelN8nGovernance, error: appState.caelN8nGovernanceError) + + if let integrations = appState.caelIntegrationStatus { + integrationsPanel(integrations) + } + + nativeFeatureMap(appState.caelWorkspaceStatus?.links ?? []) + } + } + + private func statusOverview(_ status: CaelWorkspaceStatus) -> some View { + LazyVGrid(columns: adaptiveColumns(minWidth: 220), spacing: 14) { + CaelHomebaseMetricCard( + title: "Runtime", + value: status.host, + subtitle: "BigMac personal runtime host", + systemImage: "desktopcomputer", + tint: .cyan + ) + CaelHomebaseMetricCard( + title: "Bind", + value: status.posture.bind, + subtitle: status.posture.remoteAccess, + systemImage: "network", + tint: .blue + ) + CaelHomebaseMetricCard( + title: "Auth", + value: status.ok ? "Ready" : "Needs attention", + subtitle: status.posture.auth, + systemImage: status.ok ? "checkmark.shield" : "exclamationmark.shield", + tint: status.ok ? .green : .orange + ) + CaelHomebaseMetricCard( + title: "Exposure", + value: status.posture.publicInternet, + subtitle: "Personal mesh only", + systemImage: "lock.shield", + tint: .mint + ) + } + } + + private func privateAccessMeshPanel(_ status: CaelWorkspaceStatus) -> some View { + HermesSurfacePanel( + title: "Private Access Mesh", + subtitle: "Same readiness surface as the web command center: Tailscale personal mesh, local APIs, and the Twingate business lane stay separate." + ) { + LazyVGrid(columns: adaptiveColumns(minWidth: 280), spacing: 12) { + ForEach(status.services) { service in + CaelAccessMeshCard(service: service) + } + } + } + } + + private func fastLanesPanel(_ status: CaelWorkspaceStatus) -> some View { + HermesSurfacePanel( + title: "Fast Lanes", + subtitle: "Web workspace routes mapped to native Desktop sections when the section already exists." + ) { + LazyVGrid(columns: adaptiveColumns(minWidth: 230), spacing: 12) { + ForEach(status.links) { link in + CaelFastLaneCard( + link: link, + section: nativeSection(for: link.href), + url: workspaceURL(path: link.href) + ) + } + } + } + } + + private func contextBoundariesPanel(_ status: CaelWorkspaceStatus) -> some View { + HermesSurfacePanel( + title: "Context Ownership and Boundaries", + subtitle: "Rendered from /api/cael-status contextSurfaces so Desktop and Web carry the same operating boundaries." + ) { + let surfaces = status.contextSurfaces ?? [] + if surfaces.isEmpty { + Text("No context boundary records were returned by the workspace API.") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LazyVGrid(columns: adaptiveColumns(minWidth: 280), spacing: 12) { + ForEach(surfaces) { surface in + CaelContextBoundaryCard(surface: surface) + } + } + } + } + } + + private func n8nGovernancePanel(_ governance: CaelN8nGovernanceStatus?, error: String?) -> some View { + HermesSurfacePanel( + title: "n8n Governance", + subtitle: "Health, failures, receipts, and safe actions for the personal BigMac n8n and business dev-server n8n estates." + ) { + if let governance { + VStack(alignment: .leading, spacing: 16) { + Text(governance.boundary) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + LazyVGrid(columns: adaptiveColumns(minWidth: 320), spacing: 12) { + ForEach(governance.instances) { instance in + CaelN8nInstanceCard(instance: instance) + } + } + + LazyVGrid(columns: adaptiveColumns(minWidth: 300), spacing: 12) { + CaelCommandCenterListCard( + title: "Promotion Receipts", + emptyText: "No local receipt artifacts found.", + isEmpty: governance.promotionReceipts.isEmpty + ) { + ForEach(governance.promotionReceipts.prefix(5)) { receipt in + CaelCommandCenterRow( + title: receipt.title, + detail: receipt.path, + badge: receipt.instance, + tint: .cyan + ) + } + } + + CaelCommandCenterListCard( + title: "Safe Workflow Actions", + emptyText: "No workflow commands are registered.", + isEmpty: governance.safeWorkflowCommands.isEmpty + ) { + ForEach(governance.safeWorkflowCommands.prefix(5)) { command in + CaelCommandCenterRow( + title: command.label, + detail: command.description, + badge: command.approvalRequired ? "Approval gated" : command.riskLevel, + tint: command.approvalRequired ? .orange : .green + ) + } + } + } + + HermesInsetSurface { + VStack(alignment: .leading, spacing: 8) { + Text("Guardrails") + .font(.headline) + ForEach(governance.guardrails, id: \.self) { guardrail in + Text("• \(guardrail)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } else if let error { + ContentUnavailableView( + "n8n governance unavailable", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + HermesLoadingState(label: "Loading n8n governance…", minHeight: 180) + } + } + } + + private func integrationsPanel(_ status: CaelIntegrationStatus) -> some View { + VStack(alignment: .leading, spacing: 16) { + HermesSurfacePanel( + title: "Integrations", + subtitle: "Provider readiness for Google Workspace, Vaultwarden, and legacy Twenty. Mutations stay approval-gated." + ) { + LazyVGrid(columns: adaptiveColumns(minWidth: 280), spacing: 12) { + ForEach(status.integrations) { integration in + CaelIntegrationCard(integration: integration) + } + } + } + + HermesSurfacePanel(title: "Policy") { + LazyVGrid(columns: adaptiveColumns(minWidth: 280), spacing: 12) { + ForEach(status.policy.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in + HermesInsetSurface { + VStack(alignment: .leading, spacing: 8) { + Text(key) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + } + + + + private func commandCenterSnapshot( + _ summary: CaelCommandCenterSummary, + warnings: [String] + ) -> some View { + HermesSurfacePanel( + title: "Shared Command Center", + subtitle: "Snapshot from /api/command-center/summary. Desktop and Web render the same contract." + ) { + VStack(alignment: .leading, spacing: 16) { + if !warnings.isEmpty { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 8) { + Text("Warnings") + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.orange) + ForEach(warnings, id: \.self) { warning in + Text(warning) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + LazyVGrid(columns: adaptiveColumns(minWidth: 260), spacing: 12) { + CaelCommandCenterListCard( + title: "Now / Next", + emptyText: "No focus items reported." + ) { + ForEach(summary.nowNext.prefix(5)) { item in + CaelCommandCenterRow( + title: item.label, + detail: item.detail, + tint: tint(for: item.tone) + ) + } + } + + CaelCommandCenterListCard( + title: "Action Gates", + emptyText: "No approval gates surfaced.", + isEmpty: summary.actionGates.isEmpty + ) { + ForEach(summary.actionGates.prefix(4)) { gate in + CaelCommandCenterRow( + title: gate.label, + detail: gate.detail, + badge: gate.riskLevel.replacingOccurrences(of: "_", with: " "), + tint: gate.approvalRequired ? .orange : .green + ) + } + } + + CaelCommandCenterListCard( + title: "Recent Receipts", + emptyText: "No receipt summaries found.", + isEmpty: summary.agentRuns.isEmpty + ) { + ForEach(summary.agentRuns.prefix(4)) { run in + CaelCommandCenterRow( + title: run.title, + detail: run.status, + badge: run.source, + tint: .cyan + ) + } + } + + CaelCommandCenterListCard( + title: "Models + Brain", + emptyText: "No model or brain snapshot reported." + ) { + let providers = summary.usage?.providers.filter { $0.monitorKind == "cael" || $0.caelDefault } ?? [] + CaelCommandCenterRow( + title: "Cael model monitors", + detail: providers.map { $0.caelModel ?? $0.label }.joined(separator: ", ").nilIfEmpty ?? "No active model monitors reported.", + badge: "\(providers.count)", + tint: .blue + ) + let brainCount = summary.brain?.sources.filter { $0.status == "available" }.count ?? 0 + CaelCommandCenterRow( + title: "Available brain sources", + detail: "Reference-only sources; secrets stay filtered.", + badge: "\(brainCount)", + tint: .mint + ) + } + } + } + } + } + + private func commandCenterSectionsSnapshot(_ snapshot: CaelCommandCenterSectionsSnapshot) -> some View { + HermesSurfacePanel( + title: "Command Center Sections", + subtitle: "Dedicated /api/command-center/* section endpoints for Desktop, Web, and mobile mirrors. Secrets are represented as refs only." + ) { + VStack(alignment: .leading, spacing: 16) { + if snapshot.warningCount > 0 { + HermesInsetSurface { + CaelCommandCenterRow( + title: "Section warnings", + detail: "\(snapshot.warningCount) degraded or setup-needed conditions are present across the section endpoints.", + badge: "Review", + tint: .orange + ) + } + } + + LazyVGrid(columns: adaptiveColumns(minWidth: 260), spacing: 12) { + CaelCommandCenterListCard( + title: "Action Gates", + emptyText: "No approval gates surfaced.", + isEmpty: snapshot.actionGates?.data?.actions.isEmpty ?? true + ) { + if let data = snapshot.actionGates?.data { + CaelCommandCenterRow( + title: "Approval required", + detail: "\(data.dryRun) actions support dry-run before promotion.", + badge: "\(data.approvalRequired)", + tint: data.approvalRequired > 0 ? .orange : .green + ) + ForEach(data.actions.prefix(3)) { gate in + CaelCommandCenterRow( + title: gate.label, + detail: gate.sideEffects.nilIfEmpty ?? gate.detail, + badge: gate.riskLevel.replacingOccurrences(of: "_", with: " "), + tint: gate.approvalRequired ? .orange : .green + ) + } + } + } + + CaelCommandCenterListCard( + title: "Runs + Receipts", + emptyText: "No run or receipt details reported.", + isEmpty: snapshot.agentRuns?.data?.runs.isEmpty ?? true + ) { + if let data = snapshot.agentRuns?.data { + CaelCommandCenterRow( + title: "Receipt refs", + detail: "Run history is sourced from durable receipt references.", + badge: "\(data.receipts.count)", + tint: .cyan + ) + ForEach(data.runs.prefix(3)) { run in + CaelCommandCenterRow( + title: run.title, + detail: run.verification, + badge: run.status, + tint: .cyan + ) + } + } + } + + CaelCommandCenterListCard( + title: "Brain + Memory", + emptyText: "No brain or memory artifacts reported.", + isEmpty: snapshot.brain?.data?.sources.isEmpty ?? true + ) { + if let brain = snapshot.brain?.data { + CaelCommandCenterRow( + title: "Brain sources", + detail: "\(brain.memoryArtifacts.count) memory artifact references are visible to the command center.", + badge: "\(brain.sources.count)", + tint: .mint + ) + } + ForEach((snapshot.memoryArtifacts?.data?.artifacts ?? []).prefix(3)) { artifact in + CaelCommandCenterRow( + title: artifact.title, + detail: artifact.excerpt.nilIfEmpty ?? artifact.scope, + badge: artifact.sensitivity, + tint: artifact.sensitivity == "secret_ref" ? .orange : .mint + ) + } + } + + CaelCommandCenterListCard( + title: "Automations", + emptyText: "No automation lanes reported.", + isEmpty: snapshot.automations?.data?.instances.isEmpty ?? true + ) { + if let automations = snapshot.automations?.data { + ForEach(automations.instances) { instance in + CaelCommandCenterRow( + title: instance.label, + detail: instance.health.detail, + badge: "\(instance.failures.count) failures", + tint: instance.health.ok ? .green : .orange + ) + } + } + } + + CaelCommandCenterListCard( + title: "Vault + Models", + emptyText: "No vault refs or model monitors reported." + ) { + let vaultRefs = snapshot.vaultRefs?.data?.refs ?? [] + let providers = snapshot.usageLimits?.data?.providers.filter { $0.monitorKind == "cael" || $0.caelDefault } ?? [] + CaelCommandCenterRow( + title: "Vault refs", + detail: "Reference-only pointers; secret values are not returned to the client.", + badge: "\(vaultRefs.count)", + tint: .orange + ) + CaelCommandCenterRow( + title: "Cael model monitors", + detail: providers.map { $0.caelModel ?? $0.label }.joined(separator: ", ").nilIfEmpty ?? "No active model monitors reported.", + badge: "\(providers.count)", + tint: .blue + ) + } + + CaelCommandCenterListCard( + title: "Homebase", + emptyText: "Homebase records are unavailable or degraded.", + isEmpty: snapshot.homebaseRecords?.data?.records.isEmpty ?? true + ) { + if let homebase = snapshot.homebaseRecords?.data { + CaelCommandCenterRow( + title: homebase.status, + detail: homebase.detail, + badge: "\(homebase.records.count)", + tint: homebase.status == "available" ? .green : .orange + ) + ForEach(homebase.records.prefix(3)) { record in + CaelCommandCenterRow( + title: record.label, + detail: record.updatedAt ?? "No update timestamp", + badge: record.kind, + tint: .secondary + ) + } + } + } + } + } + } + } + + private func tint(for tone: String) -> Color { + switch tone { + case "success": .green + case "warning": .orange + case "danger": .red + default: .blue + } + } + + private func nativeFeatureMap(_ links: [CaelWorkspaceLink]) -> some View { + HermesSurfacePanel( + title: "Native Feature Map", + subtitle: "Feature lanes from the web app are mapped to Desktop sections; unsupported lanes remain visible through the Fast Lanes fallback." + ) { + LazyVGrid(columns: adaptiveColumns(minWidth: 240), spacing: 12) { + ForEach(links.filter { nativeSection(for: $0.href) != nil }) { link in + if let section = nativeSection(for: link.href) { + CaelNativeFeatureCard(title: link.label, detail: link.description, section: section) + } + } + } + } + } + + private func workspaceURL(path: String) -> URL { + appState.activeConnection?.caelWorkspaceURL(path: path) ?? ConnectionProfile().caelWorkspaceURL(path: path) + } + + private func nativeSection(for href: String) -> AppSection? { + switch href { + case "/cael-home", "/dashboard": .overview + case "/desktop": .overview + case "/usage": .usage + case "/mail": .mail + case "/contacts": .contacts + case "/calendar": .calendar + case "/integrations": .integrations + case "/chat": .sessions + case "/conductor": .missionControl + case "/operations": .operations + case "/memory": .memory + case "/terminal": .terminal + case "/tasks": .kanban + case "/artifacts": .files + case "/watchdogs": .cronjobs + case "/skills": .skills + case "/mcp": .mcp + case "/profiles": .profiles + default: nil + } + } + + private func adaptiveColumns(minWidth: CGFloat) -> [GridItem] { + [GridItem(.adaptive(minimum: minWidth), spacing: 12, alignment: .top)] + } +} + + + +private struct CaelCommandCenterListCard: View { + let title: String + let emptyText: String + var isEmpty = false + let content: () -> Content + + init( + title: String, + emptyText: String, + isEmpty: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.emptyText = emptyText + self.isEmpty = isEmpty + self.content = content + } + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + if isEmpty { + Text(emptyText) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + content() + } + } + } + } +} + +private struct CaelCommandCenterRow: View { + let title: String + let detail: String + var badge: String? + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + Spacer() + if let badge { + CaelStatusBadge(label: badge.capitalized, tint: tint) + } + } + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + +private struct CaelHomebaseMetricCard: View { + let title: String + let value: String + let subtitle: String + let systemImage: String + let tint: Color + + var body: some View { + HermesSurfacePanel { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(tint) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.title3.weight(.semibold)) + .lineLimit(2) + .minimumScaleFactor(0.78) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + + +private struct CaelAccessMeshCard: View { + let service: CaelWorkspaceServiceCheck + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(service.label) + .font(.headline) + Text([service.lane, service.owner].compactMap { $0?.nilIfEmpty }.joined(separator: " / ")) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + } + + Spacer() + CaelStatusBadge(label: service.ok ? "Online" : "Needs attention", tint: service.ok ? .green : .orange) + } + + Text(service.description?.nilIfEmpty ?? service.target) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider().opacity(0.4) + + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { + GridRow { + Text("Target") + .foregroundStyle(.secondary) + Text(service.target) + } + GridRow { + Text("Health") + .foregroundStyle(.secondary) + Text(service.detail + latencySuffix) + } + } + .font(.caption) + } + } + } + + private var latencySuffix: String { + guard let latencyMs = service.latencyMs else { return "" } + return " - \(Int(latencyMs))ms" + } +} + +private struct CaelFastLaneCard: View { + @EnvironmentObject private var appState: AppState + + let link: CaelWorkspaceLink + let section: AppSection? + let url: URL + + var body: some View { + if let section { + Button { + appState.requestSectionSelection(section) + } label: { + cardContent(systemImage: section.systemImage, trailingImage: "chevron.right") + } + .buttonStyle(.plain) + } else { + Link(destination: url) { + cardContent(systemImage: "safari", trailingImage: "arrow.up.right") + } + .buttonStyle(.plain) + } + } + + private func cardContent(systemImage: String, trailingImage: String) -> some View { + HermesInsetSurface { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .frame(width: 24) + VStack(alignment: .leading, spacing: 6) { + Text(link.label) + .font(.headline) + Text(link.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Image(systemName: trailingImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + } +} + +private struct CaelContextBoundaryCard: View { + let surface: CaelWorkspaceContextSurface + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(surface.surface) + .font(.headline) + Text(surface.owner) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + } + Spacer() + } + + Text(surface.context) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider().opacity(0.4) + + VStack(alignment: .leading, spacing: 8) { + labeledText("Access", surface.access) + labeledText("Boundary", surface.boundary) + } + } + } + } + + private func labeledText(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct CaelN8nInstanceCard: View { + let instance: CaelCommandCenterAutomationInstance + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Text(instance.label) + .font(.headline) + Text(instance.scope) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + CaelStatusBadge(label: instance.health.ok ? "Online" : "Needs attention", tint: instance.health.ok ? .green : .orange) + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + CaelMiniFact(title: "Access", value: instance.access) + CaelMiniFact(title: "Boundary", value: instance.boundary) + CaelMiniFact(title: "Health", value: instance.health.detail + healthLatency) + CaelMiniFact(title: "Checked", value: instance.health.checkedAt) + } + + if instance.failures.isEmpty { + Text("No recent failure families reported by the read-only query.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Recent failure families") + .font(.subheadline.weight(.semibold)) + ForEach(instance.failures.prefix(4)) { failure in + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 3) { + Text(failure.workflowName) + .font(.caption.weight(.semibold)) + Text("Last seen \(failure.lastSeen)") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + CaelStatusBadge(label: "\(failure.status) - \(failure.count)", tint: .orange) + } + .padding(10) + .background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + } + } + + private var healthLatency: String { + guard let latency = instance.health.latencyMs else { return "" } + return " - \(Int(latency))ms" + } +} + +private struct CaelMiniFact: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct CaelSystemCard: View { + let service: CaelWorkspaceServiceCheck + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(service.label) + .font(.headline) + Text(service.target) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + CaelStatusBadge(label: service.ok ? "Online" : "Attention", tint: service.ok ? .green : .orange) + } + + Text(service.detail + latencySuffix) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var latencySuffix: String { + guard let latencyMs = service.latencyMs else { return "" } + return " · \(Int(latencyMs))ms" + } +} + +private struct CaelIntegrationCard: View { + let integration: CaelIntegrationCheck + + var body: some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + Text(integration.label) + .font(.headline) + Spacer() + CaelStatusBadge(label: badgeLabel, tint: badgeTint) + } + + Text(integration.detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(integration.safeMode) + .font(.caption) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + + private var badgeLabel: String { + switch integration.status { + case "ready": "Ready" + case "warning": "Legacy" + case "setup-needed": "Setup needed" + default: "Unknown" + } + } + + private var badgeTint: Color { + switch integration.status { + case "ready": .green + case "warning": .yellow + case "setup-needed": .orange + default: .secondary + } + } +} + +private struct CaelNativeFeatureCard: View { + @EnvironmentObject private var appState: AppState + + let title: String + let detail: String + let section: AppSection + + var body: some View { + Button { + appState.requestSectionSelection(section) + } label: { + HermesInsetSurface { + HStack(alignment: .top, spacing: 12) { + Image(systemName: section.systemImage) + .font(.title3) + .frame(width: 24) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + } + .buttonStyle(.plain) + } +} + +private struct CaelStatusBadge: View { + let label: String + let tint: Color + + var body: some View { + Text(label) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.16), in: Capsule()) + .foregroundStyle(tint) + } +} diff --git a/Sources/HermesDesktop/Views/Kanban/KanbanView.swift b/Sources/HermesDesktop/Views/Kanban/KanbanView.swift index 0030e0f..7c5990a 100644 --- a/Sources/HermesDesktop/Views/Kanban/KanbanView.swift +++ b/Sources/HermesDesktop/Views/Kanban/KanbanView.swift @@ -11,7 +11,9 @@ struct KanbanView: View { @State private var tenantFilter = KanbanFilterOption.all @State private var isCreatingTask = false @State private var isCreatingBoard = false + @State private var isEditingWorkspaceTask = false @State private var taskDraft = KanbanTaskDraft() + @State private var workspaceTaskDraft = KanbanTaskDraft() @State private var boardDraft = KanbanBoardDraft() @State private var boardPendingArchive: KanbanProject? @State private var showArchiveBoardConfirmation = false @@ -82,7 +84,9 @@ struct KanbanView: View { HStack(spacing: 8) { createTaskButton - dispatchButton + if !appState.isWorkspaceKanbanBoardSelected { + dispatchButton + } } .fixedSize(horizontal: true, vertical: false) } @@ -96,6 +100,7 @@ struct KanbanView: View { Button { isCreatingBoard = false isCreatingTask = false + isEditingWorkspaceTask = false Task { await appState.selectKanbanBoard(board.slug) } } label: { HStack { @@ -116,6 +121,7 @@ struct KanbanView: View { Button { boardDraft = KanbanBoardDraft() isCreatingTask = false + isEditingWorkspaceTask = false isCreatingBoard = true } label: { Label(L10n.string("New Board"), systemImage: "plus") @@ -126,7 +132,8 @@ struct KanbanView: View { if appState.supportsKanbanBoardManagement, let selectedBoard = appState.selectedKanbanBoard, - !selectedBoard.isDefault { + !selectedBoard.isDefault, + selectedBoard.slug != KanbanProject.workspaceTasksSlug { Divider() Button(L10n.string("Archive Board"), role: .destructive) { @@ -469,6 +476,57 @@ struct KanbanView: View { } } ) + } else if isEditingWorkspaceTask { + KanbanTaskEditorView( + draft: $workspaceTaskDraft, + errorMessage: appState.kanbanError, + isSaving: appState.isOperatingOnKanbanTask, + assignees: assigneeOptions, + onCancel: { + isEditingWorkspaceTask = false + }, + onSave: { + guard let taskID = appState.selectedKanbanTaskID else { return } + if await appState.updateWorkspaceKanbanTask(taskID: taskID, draft: workspaceTaskDraft) { + isEditingWorkspaceTask = false + } + } + ) + } else if appState.isWorkspaceKanbanBoardSelected { + WorkspaceTaskDetailView( + task: selectedTask, + errorMessage: appState.kanbanError, + operationInFlight: selectedTask.map { task in + appState.isOperatingOnKanbanTask && appState.operatingKanbanTaskID == task.id + } ?? false, + onCreate: { + taskDraft = KanbanTaskDraft() + isCreatingBoard = false + isEditingWorkspaceTask = false + isCreatingTask = true + }, + onEdit: { task in + workspaceTaskDraft = draft(from: task) + isCreatingBoard = false + isCreatingTask = false + isEditingWorkspaceTask = true + }, + onMove: { taskID, status in + await appState.moveWorkspaceKanbanTask(taskID: taskID, to: status) + }, + onLaunch: { taskID in + await appState.launchWorkspaceKanbanTaskSession(taskID: taskID) + }, + onLinkSession: { taskID, sessionID in + await appState.linkWorkspaceKanbanTaskSession(taskID: taskID, sessionID: sessionID) + }, + onOpenSession: { sessionID in + await appState.openWorkspaceTaskSession(sessionID: sessionID) + }, + onDelete: { taskID in + await appState.deleteWorkspaceKanbanTask(taskID: taskID) + } + ) } else { KanbanTaskDetailView( task: selectedTask, @@ -619,6 +677,13 @@ struct KanbanView: View { private func boardSubtitle(_ board: KanbanBoard) -> String { let boardName = appState.selectedKanbanBoard?.resolvedName ?? selectedBoardTitle + if appState.isWorkspaceKanbanBoardSelected { + return L10n.string( + "Workspace task board %@ from %@. Shared with the :3077 web and mobile app.", + boardName, + board.databasePath + ) + } return L10n.string( "Kanban board %@ at %@. SSH-native; active profile is the operator.", boardName, @@ -639,8 +704,20 @@ struct KanbanView: View { private func startCreatingTask() { taskDraft = KanbanTaskDraft() isCreatingBoard = false + isEditingWorkspaceTask = false isCreatingTask = true } + + private func draft(from task: KanbanTask) -> KanbanTaskDraft { + var draft = KanbanTaskDraft() + draft.title = task.resolvedTitle + draft.body = task.body ?? "" + draft.assignee = task.assignee ?? "" + draft.priority = task.priority + draft.skillsText = task.skills.joined(separator: ", ") + draft.startsInTriage = task.status == .triage + return draft + } } private enum KanbanStatusFilter: Hashable, CaseIterable { @@ -649,6 +726,7 @@ private enum KanbanStatusFilter: Hashable, CaseIterable { case todo case ready case running + case review case blocked case done case archived @@ -665,6 +743,8 @@ private enum KanbanStatusFilter: Hashable, CaseIterable { "Ready" case .running: "Running" + case .review: + "Review" case .blocked: "Blocked" case .done: @@ -686,6 +766,8 @@ private enum KanbanStatusFilter: Hashable, CaseIterable { .ready case .running: .running + case .review: + .review case .blocked: .blocked case .done: @@ -734,6 +816,8 @@ private enum KanbanColors { .green case .running: .orange + case .review: + .cyan case .blocked: .red case .done: @@ -746,6 +830,260 @@ private enum KanbanColors { } } +private struct WorkspaceTaskDetailView: View { + let task: KanbanTask? + let errorMessage: String? + let operationInFlight: Bool + let onCreate: () -> Void + let onEdit: (KanbanTask) -> Void + let onMove: (String, KanbanTaskStatus) async -> Void + let onLaunch: (String) async -> Void + let onLinkSession: (String, String?) async -> Void + let onOpenSession: (String) async -> Void + let onDelete: (String) async -> Void + + @State private var isLinkingSession = false + @State private var sessionLinkDraft = "" + + var body: some View { + HermesSurfacePanel(title: "Workspace Task", subtitle: "Shared with web and mobile /tasks.") { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + + if let task { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(task.resolvedTitle) + .font(.headline) + .textSelection(.enabled) + + Spacer() + + HermesBadge(text: task.status.displayTitle, tint: KanbanColors.tint(for: task.status)) + } + + if let body = task.trimmedBody { + Text(body) + .font(.body) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + VStack(alignment: .leading, spacing: 6) { + WorkspaceTaskFact(label: "ID", value: task.id) + WorkspaceTaskFact(label: "Assignee", value: task.assignee ?? "Unassigned") + WorkspaceTaskFact(label: "Priority", value: task.priorityLabel) + if let sessionID = normalizedSessionID(task.sessionID) { + WorkspaceTaskFact(label: "Session", value: sessionID) + } + } + } + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + workspaceTaskActions(task) + } + + VStack(alignment: .leading, spacing: 8) { + workspaceTaskActions(task) + } + } + + Text("Deletes use the canonical /api/hermes-tasks backend so web and desktop mutate the same task ledger.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ContentUnavailableView( + L10n.string("Select a task"), + systemImage: "checklist", + description: Text(L10n.string("Choose a Workspace task from the board, or create a new one.")) + ) + .frame(maxWidth: .infinity, minHeight: 260) + + Button { + onCreate() + } label: { + Label(L10n.string("New Task"), systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .sheet(isPresented: $isLinkingSession) { + if let task { + WorkspaceTaskLinkSessionSheet( + taskTitle: task.resolvedTitle, + sessionID: $sessionLinkDraft, + operationInFlight: operationInFlight, + onCancel: { + isLinkingSession = false + }, + onSave: { sessionID in + await onLinkSession(task.id, sessionID) + isLinkingSession = false + } + ) + } else { + EmptyView() + } + } + } + + @ViewBuilder + private func workspaceTaskActions(_ task: KanbanTask) -> some View { + Button { + onEdit(task) + } label: { + Label(L10n.string("Edit"), systemImage: "pencil") + } + .buttonStyle(.bordered) + .disabled(operationInFlight) + + Button { + Task { await onLaunch(task.id) } + } label: { + Label(L10n.string("Launch Session"), systemImage: "bubble.left.and.text.bubble.right") + } + .buttonStyle(.borderedProminent) + .disabled(operationInFlight) + + if let sessionID = normalizedSessionID(task.sessionID) { + Button { + Task { await onOpenSession(sessionID) } + } label: { + Label(L10n.string("Open Session"), systemImage: "arrow.right.circle") + } + .buttonStyle(.bordered) + .disabled(operationInFlight) + } + + Button { + sessionLinkDraft = normalizedSessionID(task.sessionID) ?? "" + isLinkingSession = true + } label: { + Label(L10n.string("Link Session"), systemImage: "link") + } + .buttonStyle(.bordered) + .disabled(operationInFlight) + + Menu { + ForEach([KanbanTaskStatus.triage, .ready, .running, .review, .blocked, .done], id: \.rawValue) { status in + Button(L10n.string(status.displayTitle)) { + Task { await onMove(task.id, status) } + } + } + + if normalizedSessionID(task.sessionID) != nil { + Divider() + Button(L10n.string("Clear Session Link")) { + Task { await onLinkSession(task.id, nil) } + } + } + } label: { + Label(L10n.string("More"), systemImage: "ellipsis.circle") + } + .buttonStyle(.bordered) + .disabled(operationInFlight) + + Button(role: .destructive) { + Task { await onDelete(task.id) } + } label: { + Label(L10n.string("Delete"), systemImage: "trash") + } + .buttonStyle(.bordered) + .disabled(operationInFlight) + } + + private func normalizedSessionID(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} + +private struct WorkspaceTaskLinkSessionSheet: View { + let taskTitle: String + @Binding var sessionID: String + let operationInFlight: Bool + let onCancel: () -> Void + let onSave: (String?) async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("Link Session")) + .font(.title3.weight(.semibold)) + Text(taskTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("Session ID")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField(L10n.string("session-id"), text: $sessionID) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + + Text(L10n.string("Launch creates a new Workspace session automatically. Link is for attaching an existing session to this task ledger.")) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Button(L10n.string("Cancel")) { + onCancel() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(L10n.string("Clear Link")) { + Task { await onSave(nil) } + } + .disabled(operationInFlight) + + Button(L10n.string("Save Link")) { + let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await onSave(trimmed.isEmpty ? nil : trimmed) } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(operationInFlight) + } + } + .padding(24) + .frame(width: 460) + } +} + +private struct WorkspaceTaskFact: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(L10n.string(label)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 74, alignment: .leading) + + Text(value) + .font(.caption) + .textSelection(.enabled) + } + } +} + private struct KanbanToolbarMenuLabel: View { let title: String let value: String diff --git a/Sources/HermesDesktop/Views/Overview/OverviewView.swift b/Sources/HermesDesktop/Views/Overview/OverviewView.swift index d6a7882..b531100 100644 --- a/Sources/HermesDesktop/Views/Overview/OverviewView.swift +++ b/Sources/HermesDesktop/Views/Overview/OverviewView.swift @@ -240,7 +240,7 @@ struct OverviewView: View { private func chatPanel(_ overview: RemoteDiscovery) -> some View { HermesSurfacePanel( title: "Chat", - subtitle: "Readiness for the embedded Hermes TUI and the transcript source read back from the host." + subtitle: "Readiness for native chat and the persisted session history read back from the host." ) { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 12) { @@ -485,7 +485,7 @@ struct OverviewView: View { HermesInspectorField( id: "session-source", label: "Session source", - value: overview.sessionStore?.kind.displayName ?? "Transcript files" + value: overview.sessionStore?.kind.displayName ?? "Session history files" ) ] @@ -502,7 +502,7 @@ struct OverviewView: View { } else { fields.append( HermesInspectorField( - id: "transcript-folder", + id: "session-history-folder", label: "Storage path", value: overview.paths.sessionsDir, isMonospaced: true, @@ -525,7 +525,7 @@ struct OverviewView: View { private var chatReadinessDetail: String { if isTUIChatReady { - return "Chat runs inside the real Hermes TUI; Sessions reads persisted transcripts from the host." + return "Chat is ready; Sessions reads persisted conversation history from the host." } return appState.nativeChatBootstrapStatus?.fallbackReason ?? diff --git a/Sources/HermesDesktop/Views/Profiles/ProfilesView.swift b/Sources/HermesDesktop/Views/Profiles/ProfilesView.swift new file mode 100644 index 0000000..f8b8856 --- /dev/null +++ b/Sources/HermesDesktop/Views/Profiles/ProfilesView.swift @@ -0,0 +1,576 @@ +import SwiftUI + +struct ProfilesView: View { + @EnvironmentObject private var appState: AppState + + @State private var profiles: [CaelProfileSummary] = [] + @State private var activeProfileName = "default" + @State private var selectedProfileName: String? + @State private var selectedProfileDetail: CaelProfileDetail? + @State private var isLoading = false + @State private var isLoadingDetail = false + @State private var operationProfileName: String? + @State private var errorMessage: String? + @State private var isPresentingCreateSheet = false + @State private var isPresentingRenameSheet = false + @State private var profilePendingDelete: CaelProfileSummary? + @State private var createName = "" + @State private var createCloneFrom = "default" + @State private var createProvider = "" + @State private var createModel = "" + @State private var renameValue = "" + @State private var descriptionDraft = "" + + var body: some View { + HermesPageContainer(width: .dashboard) { + VStack(alignment: .leading, spacing: 18) { + HermesPageHeader( + title: "Profiles", + subtitle: "Native profile management backed by the Cael Workspace /api/profiles contract. The base profile remains `default`; its display agent is Cael." + ) { + HStack(spacing: 10) { + Button { + Task { await loadProfiles(selectActive: false) } + } label: { + Label(L10n.string("Refresh"), systemImage: "arrow.clockwise") + } + .disabled(isLoading || activeConnection == nil) + + Button { + createName = "" + createCloneFrom = activeProfileName + createProvider = "" + createModel = "" + isPresentingCreateSheet = true + } label: { + Label(L10n.string("Create Profile"), systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .disabled(activeConnection == nil) + } + } + + if let errorMessage { + HermesInsetSurface { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .textSelection(.enabled) + } + } + + if activeConnection == nil { + ContentUnavailableView( + L10n.string("No active host"), + systemImage: "network.slash", + description: Text(L10n.string("Choose a host before managing Hermes profiles.")) + ) + .frame(maxWidth: .infinity, minHeight: 360) + } else { + HStack(alignment: .top, spacing: 16) { + profilesList + .frame(minWidth: 360, idealWidth: 420, maxWidth: 460) + + profileDetail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + } + .task(id: activeConnection?.commandCenterClientFingerprint) { + await loadProfiles(selectActive: true) + } + .sheet(isPresented: $isPresentingCreateSheet) { + createProfileSheet + } + .sheet(isPresented: $isPresentingRenameSheet) { + renameProfileSheet + } + .alert( + L10n.string("Delete profile?"), + isPresented: Binding( + get: { profilePendingDelete != nil }, + set: { if !$0 { profilePendingDelete = nil } } + ) + ) { + Button(L10n.string("Cancel"), role: .cancel) { + profilePendingDelete = nil + } + Button(L10n.string("Delete"), role: .destructive) { + guard let profile = profilePendingDelete else { return } + profilePendingDelete = nil + Task { await deleteProfile(profile) } + } + } message: { + Text(L10n.string("This removes the profile directory from the server-side Hermes profile registry.")) + } + } + + private var activeConnection: ConnectionProfile? { + appState.activeConnection + } + + private var selectedProfile: CaelProfileSummary? { + guard let selectedProfileName else { return nil } + return profiles.first { $0.name == selectedProfileName } + } + + private var profilesList: some View { + HermesSurfacePanel( + title: "Server Profiles", + subtitle: "Source of truth: \(activeConnection?.resolvedCaelWorkspaceBaseURL ?? ConnectionProfile.defaultCaelWorkspaceBaseURL)" + ) { + if isLoading && profiles.isEmpty { + ProgressView(L10n.string("Loading profiles…")) + .frame(maxWidth: .infinity, minHeight: 220) + } else if profiles.isEmpty { + ContentUnavailableView( + L10n.string("No profiles found"), + systemImage: "person.crop.circle.badge.questionmark", + description: Text(L10n.string("The Workspace API returned no profile records.")) + ) + .frame(maxWidth: .infinity, minHeight: 220) + } else { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(profiles) { profile in + profileRow(profile) + } + } + } + } + } + + private func profileRow(_ profile: CaelProfileSummary) -> some View { + Button { + selectedProfileName = profile.name + Task { await loadProfileDetail(profile.name) } + } label: { + HermesInsetSurface { + HStack(alignment: .top, spacing: 12) { + Image(systemName: profile.active ? "person.crop.circle.fill.badge.checkmark" : "person.crop.circle") + .font(.title3) + .foregroundStyle(profile.active ? Color.accentColor : Color.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(profile.resolvedDisplayName) + .font(.headline) + .foregroundStyle(.primary) + if profile.name == "default" { + HermesBadge(text: "base", tint: .secondary) + } + if profile.active { + HermesBadge(text: "active", tint: .accentColor) + } + } + + Text(profile.name) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let description = profile.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + HStack(spacing: 10) { + ProfileMiniStat(label: "skills", value: "\(profile.skillCount)") + ProfileMiniStat(label: "sessions", value: "\(profile.sessionCount)") + if profile.hasEnv { + ProfileMiniStat(label: "env", value: "yes") + } + } + } + + Spacer(minLength: 8) + + if selectedProfileName == profile.name { + Image(systemName: "chevron.right.circle.fill") + .foregroundStyle(Color.accentColor) + } + } + } + } + .buttonStyle(.plain) + } + + private var profileDetail: some View { + HermesSurfacePanel( + title: selectedProfile?.resolvedDisplayName ?? "Profile Detail", + subtitle: selectedProfile.map { "Profile id: \($0.name)" } ?? "Select a server profile to inspect or operate on it." + ) { + if isLoadingDetail { + ProgressView(L10n.string("Loading profile…")) + .frame(maxWidth: .infinity, minHeight: 360) + } else if let profile = selectedProfile { + VStack(alignment: .leading, spacing: 16) { + profileActionBar(profile) + profileMetadata(profile) + descriptionEditor(profile) + if let selectedProfileDetail { + detailPaths(selectedProfileDetail) + } + } + } else { + ContentUnavailableView( + L10n.string("No profile selected"), + systemImage: "person.text.rectangle", + description: Text(L10n.string("Choose a profile from the server registry.")) + ) + .frame(maxWidth: .infinity, minHeight: 360) + } + } + } + + private func profileActionBar(_ profile: CaelProfileSummary) -> some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + profileButtons(profile) + Spacer(minLength: 10) + profileDestructiveButton(profile) + } + VStack(alignment: .leading, spacing: 10) { + profileButtons(profile) + profileDestructiveButton(profile) + } + } + } + + private func profileButtons(_ profile: CaelProfileSummary) -> some View { + HStack(spacing: 10) { + Button { + Task { await activateProfile(profile) } + } label: { + Label(L10n.string("Activate"), systemImage: "checkmark.circle") + } + .buttonStyle(.borderedProminent) + .disabled(profile.active || operationProfileName != nil) + + Button { + renameValue = profile.name == "default" ? "" : profile.name + isPresentingRenameSheet = true + } label: { + Label(L10n.string("Rename"), systemImage: "pencil") + } + .buttonStyle(.bordered) + .disabled(profile.name == "default" || operationProfileName != nil) + + Button { + Task { await loadProfileDetail(profile.name) } + } label: { + Label(L10n.string("Refresh"), systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(operationProfileName != nil) + } + } + + private func profileDestructiveButton(_ profile: CaelProfileSummary) -> some View { + Button(role: .destructive) { + profilePendingDelete = profile + } label: { + Label(L10n.string("Delete"), systemImage: "trash") + } + .buttonStyle(.borderless) + .disabled(profile.name == "default" || profile.active || operationProfileName != nil) + } + + private func profileMetadata(_ profile: CaelProfileSummary) -> some View { + HermesInsetSurface { + VStack(alignment: .leading, spacing: 12) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 18) { + profileValue(label: "Display", value: profile.resolvedDisplayName) + profileValue(label: "Base Profile", value: profile.name) + profileValue(label: "Provider", value: profile.provider ?? "Not set") + profileValue(label: "Model", value: profile.model ?? "Not set") + } + VStack(alignment: .leading, spacing: 12) { + profileValue(label: "Display", value: profile.resolvedDisplayName) + profileValue(label: "Base Profile", value: profile.name) + profileValue(label: "Provider", value: profile.provider ?? "Not set") + profileValue(label: "Model", value: profile.model ?? "Not set") + } + } + + Text(profile.path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + } + } + } + + private func descriptionEditor(_ profile: CaelProfileSummary) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Description")) + .font(.headline) + TextEditor(text: $descriptionDraft) + .font(.body) + .frame(minHeight: 96) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1) + ) + HStack { + Button { + Task { await saveDescription(profile) } + } label: { + Label(L10n.string("Save Description"), systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .disabled(operationProfileName != nil) + + Spacer() + } + } + } + + private func detailPaths(_ detail: CaelProfileDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Server Paths")) + .font(.headline) + profileValue(label: "Hermes home", value: detail.path) + profileValue(label: "Sessions", value: detail.sessionsDir ?? "Not available") + profileValue(label: "Skills", value: detail.skillsDir ?? "Not available") + profileValue(label: "Env linked", value: detail.hasEnv ? "yes" : "no") + } + } + + private func profileValue(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.string(label)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.subheadline, design: label == "Display" ? .default : .monospaced)) + .textSelection(.enabled) + .lineLimit(3) + } + } + + private var createProfileSheet: some View { + VStack(alignment: .leading, spacing: 18) { + Text(L10n.string("Create Profile")) + .font(.title2.weight(.semibold)) + profileTextField("Name", text: $createName, prompt: "builder") + profileTextField("Clone from", text: $createCloneFrom, prompt: "default") + profileTextField("Provider", text: $createProvider, prompt: "optional") + profileTextField("Model", text: $createModel, prompt: "optional") + HStack { + Spacer() + Button(L10n.string("Cancel")) { + isPresentingCreateSheet = false + } + Button(L10n.string("Create")) { + Task { await createProfile() } + } + .buttonStyle(.borderedProminent) + .disabled(!isCreateNameValid || operationProfileName != nil) + } + } + .padding(24) + .frame(width: 460) + } + + private var renameProfileSheet: some View { + VStack(alignment: .leading, spacing: 18) { + Text(L10n.string("Rename Profile")) + .font(.title2.weight(.semibold)) + profileTextField("New name", text: $renameValue, prompt: "researcher") + HStack { + Spacer() + Button(L10n.string("Cancel")) { + isPresentingRenameSheet = false + } + Button(L10n.string("Rename")) { + Task { await renameProfile() } + } + .buttonStyle(.borderedProminent) + .disabled(!isRenameNameValid || operationProfileName != nil) + } + } + .padding(24) + .frame(width: 420) + } + + private func profileTextField(_ label: String, text: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string(label)) + .font(.caption) + .foregroundStyle(.secondary) + TextField(prompt, text: text) + .textFieldStyle(.roundedBorder) + } + } + + private var isCreateNameValid: Bool { + isProfileNameValid(createName) && createName.trimmingCharacters(in: .whitespacesAndNewlines) != "default" + } + + private var isRenameNameValid: Bool { + isProfileNameValid(renameValue) && renameValue.trimmingCharacters(in: .whitespacesAndNewlines) != "default" + } + + private func isProfileNameValid(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 64 else { return false } + return trimmed.range(of: #"^[A-Za-z0-9][A-Za-z0-9_-]*$"#, options: .regularExpression) != nil + } + + private func loadProfiles(selectActive: Bool) async { + guard let connection = activeConnection else { + profiles = [] + selectedProfileName = nil + selectedProfileDetail = nil + return + } + + isLoading = true + errorMessage = nil + do { + let response = try await appState.caelWorkspaceAPIService.loadProfiles(connection: connection) + profiles = response.profiles + activeProfileName = response.activeProfile + if selectActive || selectedProfileName == nil || profiles.contains(where: { $0.name == selectedProfileName }) == false { + selectedProfileName = response.activeProfile + } + if let selectedProfileName { + await loadProfileDetail(selectedProfileName) + } + isLoading = false + } catch { + isLoading = false + errorMessage = error.localizedDescription + } + } + + private func loadProfileDetail(_ name: String) async { + guard let connection = activeConnection else { return } + isLoadingDetail = true + errorMessage = nil + do { + let detail = try await appState.caelWorkspaceAPIService.readProfile(connection: connection, name: name) + guard selectedProfileName == name else { return } + selectedProfileDetail = detail + descriptionDraft = detail.description + isLoadingDetail = false + } catch { + isLoadingDetail = false + errorMessage = error.localizedDescription + } + } + + private func createProfile() async { + guard let connection = activeConnection else { return } + let name = createName.trimmingCharacters(in: .whitespacesAndNewlines) + operationProfileName = "__create__" + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.createProfile( + connection: connection, + name: name, + cloneFrom: createCloneFrom, + provider: createProvider, + model: createModel + ) + isPresentingCreateSheet = false + selectedProfileName = name + operationProfileName = nil + await loadProfiles(selectActive: false) + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func activateProfile(_ profile: CaelProfileSummary) async { + guard let connection = activeConnection else { return } + operationProfileName = profile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.activateProfile(connection: connection, name: profile.name) + operationProfileName = nil + await appState.switchHermesProfile(to: profile.name) + await loadProfiles(selectActive: true) + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func renameProfile() async { + guard let connection = activeConnection, + let selectedProfile, + selectedProfile.name != "default" else { + return + } + let newName = renameValue.trimmingCharacters(in: .whitespacesAndNewlines) + operationProfileName = selectedProfile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.renameProfile(connection: connection, oldName: selectedProfile.name, newName: newName) + isPresentingRenameSheet = false + selectedProfileName = newName + operationProfileName = nil + await loadProfiles(selectActive: false) + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func saveDescription(_ profile: CaelProfileSummary) async { + guard let connection = activeConnection else { return } + operationProfileName = profile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.updateProfileDescription( + connection: connection, + name: profile.name, + description: descriptionDraft.trimmingCharacters(in: .whitespacesAndNewlines) + ) + operationProfileName = nil + await loadProfiles(selectActive: false) + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } + + private func deleteProfile(_ profile: CaelProfileSummary) async { + guard let connection = activeConnection else { return } + operationProfileName = profile.name + errorMessage = nil + do { + _ = try await appState.caelWorkspaceAPIService.deleteProfile(connection: connection, name: profile.name) + if selectedProfileName == profile.name { + selectedProfileName = activeProfileName + selectedProfileDetail = nil + } + operationProfileName = nil + await loadProfiles(selectActive: false) + } catch { + operationProfileName = nil + errorMessage = error.localizedDescription + } + } +} + +private struct ProfileMiniStat: View { + let label: String + let value: String + + var body: some View { + Text("\(value) \(label)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.secondary.opacity(0.12), in: Capsule()) + } +} diff --git a/Sources/HermesDesktop/Views/RootView.swift b/Sources/HermesDesktop/Views/RootView.swift index 60bfb97..e4e1226 100644 --- a/Sources/HermesDesktop/Views/RootView.swift +++ b/Sources/HermesDesktop/Views/RootView.swift @@ -62,7 +62,7 @@ struct RootView: View { } ToolbarItem(placement: .principal) { - HermesToolbarPrincipalTitle(title: "Hermes Desktop") + HermesToolbarPrincipalTitle(title: "Cael Desktop") } ToolbarItemGroup(placement: .automatic) { @@ -218,7 +218,7 @@ struct RootView: View { return $filesSplitLayout case .skills: return $skillsSplitLayout - case .connections, .overview, .usage, .terminal: + case .connections, .overview, .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .usage, .memory, .integrations, .mcp, .profiles, .terminal: return nil } } @@ -241,7 +241,28 @@ struct RootView: View { if appState.activeConnection == nil { return [.connections] } - return [.connections, .overview, .sessions, .workflows, .cronjobs, .kanban, .files, .usage, .skills, .terminal] + return [ + .connections, + .overview, + .sessions, + .mail, + .contacts, + .calendar, + .workflows, + .cronjobs, + .kanban, + .files, + .terminal, + .missionControl, + .operations, + .swarm, + .usage, + .memory, + .skills, + .integrations, + .mcp, + .profiles + ] } private var sectionSelection: Binding { @@ -286,11 +307,15 @@ struct RootView: View { case .connections: ConnectionsView() case .overview: - OverviewView() + CaelWorkspaceWebView() case .files: FilesView(splitLayout: $filesSplitLayout) case .sessions: EmptyView() + case .mail, .contacts, .calendar, .missionControl, .operations, .swarm, .memory, .integrations, .mcp: + CommandCenterMirrorView(section: appState.selectedSection) + case .profiles: + ProfilesView() case .workflows: WorkflowsView(splitLayout: $workflowsSplitLayout) case .cronjobs: @@ -428,7 +453,7 @@ private struct WorkspaceSidebarCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(L10n.string("Hermes Profile")) + Text(L10n.string("Agent")) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) @@ -441,15 +466,15 @@ private struct WorkspaceSidebarCard: View { } } label: { if profile.name == connection.resolvedHermesProfileName { - Label(profile.name, systemImage: "checkmark") + Label(profile.displayTitle, systemImage: "checkmark") } else { - Text(profile.name) + Text(profile.displayTitle) } } } } label: { HStack(spacing: 8) { - Text(connection.resolvedHermesProfileName) + Text(connection.agentDisplayName) .font(.headline) .lineLimit(1) .truncationMode(.tail) @@ -467,7 +492,7 @@ private struct WorkspaceSidebarCard: View { .buttonStyle(.plain) .disabled(appState.isRefreshingOverview || appState.isBusy) } else { - Text(connection.resolvedHermesProfileName) + Text(connection.agentDisplayName) .font(.headline) .lineLimit(1) .truncationMode(.tail) @@ -500,7 +525,8 @@ private struct WorkspaceSidebarCard: View { name: connection.resolvedHermesProfileName, path: connection.remoteHermesHomePath, isDefault: connection.usesDefaultHermesProfile, - exists: true + exists: true, + displayName: connection.agentDisplayName ) ] } diff --git a/Sources/HermesDesktop/Views/Sessions/SessionDetailView.swift b/Sources/HermesDesktop/Views/Sessions/SessionDetailView.swift index 6a1ee48..6b33d1d 100644 --- a/Sources/HermesDesktop/Views/Sessions/SessionDetailView.swift +++ b/Sources/HermesDesktop/Views/Sessions/SessionDetailView.swift @@ -4,6 +4,7 @@ import SwiftUI private let sessionDetailBottomID = "session-detail-bottom" private let approvalNeededMessage = "Hermes requested command approval, but this chat turn cannot collect manual approvals. Retry this turn with Auto-approve enabled, or resume the session in Terminal to review the command yourself." private let autoApproveHelpText = "Approves command requests for this turn. Without it, approval-required commands may be blocked in chat." +private let sessionImageAttachmentMaxBytes = 10 * 1024 * 1024 private func sessionMessageScrollID(_ message: SessionMessageDisplay) -> String { "session-message-\(message.id)" @@ -75,9 +76,16 @@ struct SessionDetailView: View { let session: SessionSummary? let messages: [SessionMessageDisplay] let errorMessage: String? + let conversationError: String? + let isSendingMessage: Bool let isDeletingSession: Bool let isSessionPinned: Bool let sessionCompactionNotice: SessionCompactionNotice? + let activeRun: WorkspaceSessionActiveRun? + let pendingTurn: PendingSessionTurn? + let liveMessages: [SessionMessageDisplay] + let liveToolActivityCards: [HermesToolActivityCard] + let promptCards: [HermesPromptCard] let mode: SessionDetailMode let terminal: SessionTUITerminal? let terminalTheme: TerminalThemePreference @@ -90,6 +98,9 @@ struct SessionDetailView: View { let onToggleSessionPin: (SessionSummary) -> Void let onModeChange: (SessionDetailMode) -> Void let onStartChat: () -> Void + let onStartSession: (String, Bool, [WorkspaceChatAttachment]) async -> Bool + let onSendMessage: (String, Bool, [WorkspaceChatAttachment]) async -> Bool + let onRespondToPrompt: (HermesPromptCard, HermesPromptResponse) async -> Void let onUpdateTerminalTheme: (TerminalThemePreference) -> Void let onTerminalExitRefresh: () async -> Void @@ -102,7 +113,7 @@ struct SessionDetailView: View { @State private var shouldAutoScrollNextMessageLoad = true private var latestMessageScrollKey: String { - "\(messages.count):\(messages.last?.id ?? "none")" + "\(messages.count):\(messages.last?.id ?? "none"):live=\(liveMessages.count):\(liveMessages.last?.id ?? "none"):pending=\(pendingTurn?.id.uuidString ?? "none"):prompts=\(promptCards.count):tools=\(liveToolActivityCards.count)" } var body: some View { @@ -111,11 +122,7 @@ struct SessionDetailView: View { Divider() - if mode == .transcript { - transcriptMode - } else { - chatMode - } + nativeChatMode } .alert(L10n.string("Delete this session?"), isPresented: $showDeleteConfirmation, presenting: session) { session in Button(L10n.string("Delete"), role: .destructive) { @@ -159,26 +166,12 @@ struct SessionDetailView: View { } } - Picker("", selection: modeBinding) { - Text(L10n.string("Transcript")).tag(SessionDetailMode.transcript) - Text(L10n.string("Chat")).tag(SessionDetailMode.chat) - } - .pickerStyle(.segmented) - .fixedSize(horizontal: true, vertical: false) } .padding(.horizontal, 24) .padding(.vertical, 16) .background(.bar) } - private var modeBinding: Binding { - Binding { - mode - } set: { newValue in - onModeChange(newValue) - } - } - private var terminalThemeBinding: Binding { Binding { terminalTheme @@ -189,66 +182,72 @@ struct SessionDetailView: View { private var newChatSubtitle: String { guard let connection else { - return L10n.string("Select an SSH host to start a live Hermes TUI.") + return L10n.string("Select an SSH host to start a live Hermes chat.") } return "\(connection.label) - \(connection.displayDestination) - \(connection.resolvedHermesProfileName)" } - private var transcriptMode: some View { - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 18) { - transcriptScrollContent + private var nativeChatMode: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 18) { + nativeChatScrollContent - Color.clear - .frame(height: 1) - .id(sessionDetailBottomID) - } - .padding(.horizontal, 24) - .padding(.vertical, 22) - } - .background { - SessionScrollOffsetObserver( - sessionID: session?.id, - savedOffset: savedScrollOffset, - restoreRequestID: scrollOffsetRestoreRequestID, - onSaveOffset: onSaveScrollOffset, - onMetricsChange: { metrics in - scrollMetrics = metrics + Color.clear + .frame(height: 1) + .id(sessionDetailBottomID) } - ) - } - .overlay(alignment: .bottomTrailing) { - if shouldShowJumpToLatestButton { - Button { - requestScrollToLatest(proxy, reason: .pendingTurnChanged) - } label: { - Label(L10n.string("Jump to Latest"), systemImage: "arrow.down.to.line") + .padding(.horizontal, 24) + .padding(.vertical, 22) + } + .background { + SessionScrollOffsetObserver( + sessionID: session?.id, + savedOffset: savedScrollOffset, + restoreRequestID: scrollOffsetRestoreRequestID, + onSaveOffset: onSaveScrollOffset, + onMetricsChange: { metrics in + scrollMetrics = metrics + } + ) + } + .overlay(alignment: .bottomTrailing) { + if shouldShowJumpToLatestButton { + Button { + requestScrollToLatest(proxy, reason: .pendingTurnChanged) + } label: { + Label(L10n.string("Jump to Latest"), systemImage: "arrow.down.to.line") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .padding(18) + .transition(.opacity) + .help(L10n.string("Scroll to the latest message")) } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .padding(18) - .transition(.opacity) - .help(L10n.string("Scroll to the latest message")) + } + .onChange(of: session?.id) { _, _ in + expandedMetadataMessageIDs.removeAll() + shouldAutoScrollNextMessageLoad = true + restoreSavedScrollOffsetOrScrollToLatest(proxy) + } + .onChange(of: latestMessageScrollKey) { _, _ in + handleMessageScrollChange(proxy) + } + .task(id: session?.id) { + restoreSavedScrollOffsetOrScrollToLatest(proxy) } } - .onChange(of: session?.id) { _, _ in - expandedMetadataMessageIDs.removeAll() - shouldAutoScrollNextMessageLoad = true - restoreSavedScrollOffsetOrScrollToLatest(proxy) - } - .onChange(of: latestMessageScrollKey) { _, _ in - guard session != nil, !messages.isEmpty else { return } - handleMessageScrollChange(proxy) - } - .task(id: session?.id) { - restoreSavedScrollOffsetOrScrollToLatest(proxy) - } + + Divider() + .opacity(0.6) + + composerDock } } @ViewBuilder - private var transcriptScrollContent: some View { + private var nativeChatScrollContent: some View { if let errorMessage { HermesSurfacePanel { Text(errorMessage) @@ -262,13 +261,18 @@ struct SessionDetailView: View { SessionCompactionNoticeView(notice: sessionCompactionNotice) } - transcriptContent(for: session) + nativeConversationContent(for: session) + } else if let pendingTurn, pendingTurn.sessionID == nil { + HermesSurfacePanel(title: "Starting Session") { + PendingSessionTurnView(turn: pendingTurn, showPrompt: !allChatMessages.containsUserPrompt(pendingTurn.prompt)) + .id(pendingTurnScrollID(pendingTurn)) + } } else { HermesSurfacePanel { ContentUnavailableView( L10n.string("Start or select a session"), systemImage: "bubble.left.and.bubble.right", - description: Text(L10n.string("Use New Chat to start the real Hermes TUI, or choose an existing session to inspect its stored transcript.")) + description: Text(L10n.string("Write below to begin a new Hermes conversation, or choose an existing session from the list.")) ) .frame(maxWidth: .infinity, minHeight: 320) } @@ -276,76 +280,112 @@ struct SessionDetailView: View { } @ViewBuilder - private func transcriptContent(for session: SessionSummary) -> some View { - if messages.isEmpty { + private func nativeConversationContent(for session: SessionSummary) -> some View { + let matchingPendingTurn = pendingTurn?.sessionID == session.id ? pendingTurn : nil + + if allChatMessages.isEmpty && matchingPendingTurn == nil && liveToolActivityCards.isEmpty && promptCards.isEmpty && activeRunForSession(session) == nil { HermesSurfacePanel { ContentUnavailableView( - L10n.string("No transcript entries"), + L10n.string("No messages yet"), systemImage: "text.bubble", description: Text(L10n.string("This session has no readable message rows yet.")) ) .frame(maxWidth: .infinity, minHeight: 280) } } else { - HermesSurfacePanel( - title: "Transcript", - subtitle: "Messages are shown in the order Hermes stored them for this session." - ) { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(messages) { message in - MessageCard( - message: message, - isShowingMetadata: metadataExpansionBinding(for: message.id) - ) - .id(sessionMessageScrollID(message)) - } + LazyVStack(alignment: .leading, spacing: 14) { + if let run = activeRunForSession(session) { + WorkspaceSessionActiveRunCard(run: run) } - } - } - } - - @ViewBuilder - private var chatMode: some View { - if let terminal, terminalMatchesCurrentSelection(terminal) { - VStack(spacing: 0) { - HStack(spacing: 10) { - Image(systemName: terminal.terminalSession.isRunning ? "terminal.fill" : "terminal") - .foregroundStyle(terminal.terminalSession.isRunning ? Color.green : Color.secondary) - Text(L10n.string(terminal.targetLabel)) - .font(.subheadline.weight(.semibold)) + ForEach(allChatMessages) { message in + MessageCard( + message: message, + isShowingMetadata: metadataExpansionBinding(for: message.id) + ) + .id(sessionMessageScrollID(message)) + } - Spacer() + ForEach(liveToolActivityCards) { card in + SessionToolActivityCardView(card: card) + } - TerminalAppearanceToolbarButton( - appearance: terminalAppearance, - isPresented: $isShowingChatAppearanceEditor, - themePreference: terminalThemeBinding + ForEach(promptCards) { card in + SessionPromptCardView( + card: card, + isDisabled: isSendingMessage, + onRespond: onRespondToPrompt ) + } - if let exitCode = terminal.terminalSession.exitCode { - HermesBadge(text: L10n.string("Exited %@", "\(exitCode)"), tint: exitCode == 0 ? .secondary : .orange) - } else if terminal.terminalSession.isRunning { - HermesBadge(text: L10n.string("Running"), tint: Color(red: 0.0, green: 0.58, blue: 0.22)) - } + if let matchingPendingTurn { + PendingSessionTurnView( + turn: matchingPendingTurn, + showPrompt: !allChatMessages.containsUserPrompt(matchingPendingTurn.prompt) + ) + .id(pendingTurnScrollID(matchingPendingTurn)) } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.secondary.opacity(0.06)) - - SwiftTermTerminalView( - session: terminal.terminalSession, - appearance: terminalAppearance, - isActive: isActive && mode == .chat - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .onChange(of: terminal.terminalSession.exitCode) { _, _ in - Task { await onTerminalExitRefresh() } } - } else { - sessionChatPlaceholder + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var allChatMessages: [SessionMessageDisplay] { + var seen = Set() + return (messages + liveMessages).filter { message in + let key = sessionMessageDeduplicationKey(for: message) + return seen.insert(key).inserted + } + } + + private func sessionMessageDeduplicationKey( + for message: SessionMessageDisplay + ) -> String { + let text = (message.content ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + guard !text.isEmpty else { + return "id:\(message.id)" } + return "\(sessionMessageRoleKey(message.role)):\(text)" + } + + private func sessionMessageRoleKey(_ role: SessionMessageRole) -> String { + switch role { + case .assistant: + return "assistant" + case .user: + return "user" + case .system: + return "system" + case .event: + return "event" + case .custom(let value): + return value + } + } + + private func activeRunForSession(_ session: SessionSummary) -> WorkspaceSessionActiveRun? { + guard let activeRun, activeRun.sessionKey == session.id else { return nil } + return activeRun + } + + private var composerDock: some View { + SessionComposerPanel( + title: session == nil ? "New Session" : "Continue Session", + placeholder: session == nil ? "Start a new Hermes session…" : "Write a reply to continue this session…", + errorMessage: conversationError, + isSending: isSendingMessage, + showsAutoApprove: true, + onResumeInTerminal: session.map { selectedSession in + { onResumeInTerminal(selectedSession) } + }, + onSend: session == nil ? onStartSession : onSendMessage + ) + .id(session?.id ?? "new-session") + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(.bar) } @ViewBuilder @@ -396,7 +436,7 @@ struct SessionDetailView: View { if let session { return L10n.string("Hermes TUI will resume %@ over the existing SSH-first terminal path.", shortSessionID(session.id)) } - return L10n.string("Hermes TUI will create the next session on the host; refresh Sessions after it exits or when you return to Transcript.") + return L10n.string("Hermes TUI will create the next session on the host; the conversation will appear here when it is available.") } private var startChatButtonTitle: String { @@ -429,7 +469,7 @@ struct SessionDetailView: View { private var shouldShowJumpToLatestButton: Bool { guard session != nil, - hasLatestTranscriptTarget, + hasLatestChatTarget, !scrollRequest.isPending else { return false } @@ -437,8 +477,9 @@ struct SessionDetailView: View { return scrollMetrics.distanceToBottom > 96 } - private var hasLatestTranscriptTarget: Bool { - !messages.isEmpty + private var hasLatestChatTarget: Bool { + let hasMatchingPendingTurn = pendingTurn.map { $0.sessionID == nil || $0.sessionID == session?.id } ?? false + return !allChatMessages.isEmpty || hasMatchingPendingTurn || !promptCards.isEmpty || !liveToolActivityCards.isEmpty } private var isNearLatest: Bool { @@ -523,7 +564,12 @@ struct SessionDetailView: View { } private var latestScrollTarget: (id: String, anchor: UnitPoint) { - if let lastMessage = messages.last { + if let pendingTurn, + pendingTurn.sessionID == nil || pendingTurn.sessionID == session?.id { + return (pendingTurnScrollID(pendingTurn), .top) + } + + if let lastMessage = allChatMessages.last { return (sessionMessageScrollID(lastMessage), .top) } @@ -752,6 +798,171 @@ private final class SessionScrollOffsetProbeView: NSView { } } + +private struct WorkspaceSessionActiveRunCard: View { + let run: WorkspaceSessionActiveRun + + private var statusText: String { + run.status.replacingOccurrences(of: "_", with: " ").capitalized + } + + private var statusTint: Color { + switch run.status.lowercased() { + case "error", "failed": + return .red + case "complete", "completed", "succeeded": + return .green + default: + return .cyan + } + } + + private var detailText: String { + if let errorMessage = run.errorMessage, !errorMessage.isEmpty { + return errorMessage + } + if let thinkingText = run.thinkingText, !thinkingText.isEmpty { + return thinkingText + } + if let assistantText = run.assistantText, !assistantText.isEmpty { + return assistantText + } + return L10n.string("Server-owned chat turn is being tracked by the Workspace run ledger.") + } + + private var recentLifecycleEvents: [WorkspaceSessionRunLifecycleEvent] { + Array(run.lifecycleEvents.suffix(6)) + } + + var body: some View { + HermesSurfacePanel { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Label(statusText, systemImage: run.status == "error" ? "exclamationmark.triangle.fill" : "waveform.path.ecg") + .font(.caption.weight(.semibold)) + .foregroundStyle(statusTint) + + Text(run.runId) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + if let lastEventAt = run.lastEventAt { + Text(runLedgerTimestamp(lastEventAt)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Text(detailText) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(4) + + if !recentLifecycleEvents.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("Run Events")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(recentLifecycleEvents) { event in + HStack(alignment: .top, spacing: 8) { + Text(nonBlank(event.emoji) ?? (event.isError ? "!" : "-")) + .font(.caption.weight(.semibold)) + .foregroundStyle(event.isError ? .red : .secondary) + .frame(width: 16, alignment: .center) + + Text(event.text) + .font(.caption) + .foregroundStyle(event.isError ? .red : .secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + + if let timestamp = event.timestamp { + Text(runLedgerTimestamp(timestamp)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + .padding(10) + .background(Color.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + if !run.toolCalls.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Tools")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(run.toolCalls.prefix(6)) { tool in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + HermesBadge( + text: tool.phase.replacingOccurrences(of: "_", with: " ").capitalized, + tint: toolPhaseTint(tool.phase), + systemImage: "wrench.and.screwdriver.fill", + isMonospaced: false + ) + + Text(tool.name) + .font(.caption.weight(.semibold)) + .lineLimit(1) + + Spacer() + } + + if let preview = nonBlank(tool.preview) { + Text(preview) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(4) + .textSelection(.enabled) + } + + if let result = nonBlank(tool.result) { + Text(result) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(4) + .textSelection(.enabled) + } + } + .padding(10) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + } + } + + private func toolPhaseTint(_ phase: String) -> Color { + switch phase.lowercased() { + case "complete", "completed", "succeeded", "success": + return .green + case "error", "failed": + return .red + default: + return .blue + } + } + + private func nonBlank(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private func runLedgerTimestamp(_ value: Double) -> String { + let seconds = value > 10_000_000_000 ? value / 1000 : value + let date = Date(timeIntervalSince1970: seconds) + return DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + } +} + private struct SessionSummaryPanel: View { let session: SessionSummary let isDeleting: Bool @@ -866,9 +1077,11 @@ private struct SessionComposerPanel: View { let isSending: Bool let showsAutoApprove: Bool let onResumeInTerminal: (() -> Void)? - let onSend: (String, Bool) async -> Bool + let onSend: (String, Bool, [WorkspaceChatAttachment]) async -> Bool @State private var draft = "" + @State private var attachments: [WorkspaceChatAttachment] = [] + @State private var attachmentError: String? @State private var autoApproveCommands = false @State private var isExpanded = false @FocusState private var isEditorFocused: Bool @@ -887,7 +1100,7 @@ private struct SessionComposerPanel: View { } private var canSend: Bool { - !isSending && !trimmedDraft.isEmpty + !isSending && (!trimmedDraft.isEmpty || !attachments.isEmpty) } private var shouldUseExpandedEditor: Bool { @@ -944,6 +1157,8 @@ private struct SessionComposerPanel: View { } } + attachmentStrip + composerInput } .padding(12) @@ -1045,7 +1260,9 @@ private struct SessionComposerPanel: View { placeholder: placeholderText, isFocused: $isEditorFocused, isDisabled: isSending, - onCommandReturn: submit + onCommandReturn: submit, + onImagePaste: addImageAttachment, + onImagePasteError: showImageAttachmentError ) .padding(contentPadding) .frame(height: height) @@ -1058,6 +1275,62 @@ private struct SessionComposerPanel: View { } } + @ViewBuilder + private var attachmentStrip: some View { + if !attachments.isEmpty || attachmentError != nil { + VStack(alignment: .leading, spacing: 6) { + if !attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 6) { + Image(systemName: "photo") + .foregroundStyle(Color.accentColor) + + Text(attachment.name) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 220, alignment: .leading) + + Text(byteCountString(attachment.size)) + .foregroundStyle(.secondary) + + Button { + removeAttachment(attachment) + } label: { + Image(systemName: "xmark.circle.fill") + .imageScale(.small) + } + .buttonStyle(.plain) + .help(L10n.string("Remove attachment")) + .accessibilityLabel(L10n.string("Remove attachment")) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background { + Capsule(style: .continuous) + .fill(HermesTheme.insetFill) + } + .overlay { + Capsule(style: .continuous) + .strokeBorder(HermesTheme.subtleStroke, lineWidth: 1) + } + } + } + } + } + + if let attachmentError { + Label(attachmentError, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + private var controlCluster: some View { HStack(spacing: 8) { skillInsertMenu @@ -1155,18 +1428,26 @@ private struct SessionComposerPanel: View { } private func submit() { - let prompt = trimmedDraft - guard !isSending, !prompt.isEmpty else { return } + let originalPrompt = trimmedDraft + let outgoingAttachments = attachments + guard !isSending, !originalPrompt.isEmpty || !outgoingAttachments.isEmpty else { return } + + let prompt = originalPrompt.isEmpty + ? L10n.string(outgoingAttachments.count == 1 ? "Please review the attached image." : "Please review the attached images.") + : originalPrompt let shouldAutoApprove = autoApproveCommands autoApproveCommands = false isExpanded = false isEditorFocused = false draft = "" + attachments = [] + attachmentError = nil Task { - let didSend = await onSend(prompt, shouldAutoApprove) - if !didSend && draft.isEmpty { - draft = prompt + let didSend = await onSend(prompt, shouldAutoApprove, outgoingAttachments) + if !didSend && draft.isEmpty && attachments.isEmpty { + draft = originalPrompt + attachments = outgoingAttachments isExpanded = shouldExpandEditor(for: prompt) } } @@ -1197,6 +1478,23 @@ private struct SessionComposerPanel: View { expandEditor() } + private func addImageAttachment(_ attachment: WorkspaceChatAttachment) { + guard !isSending else { return } + attachmentError = nil + attachments.append(attachment) + expandEditor() + } + + private func showImageAttachmentError(_ message: String) { + guard !isSending else { return } + attachmentError = message + expandEditor() + } + + private func removeAttachment(_ attachment: WorkspaceChatAttachment) { + attachments.removeAll { $0.id == attachment.id } + } + private func preserveEditorFocusAfterLayoutChange() { guard isEditorFocused, !isSending else { return } DispatchQueue.main.async { @@ -1222,6 +1520,61 @@ private struct SessionComposerPanel: View { } } +private enum PastedImageAttachmentResult { + case success(WorkspaceChatAttachment) + case failure(String) +} + +private func pastedImageAttachment(from pasteboard: NSPasteboard) -> PastedImageAttachmentResult? { + guard let imageData = pastedImagePNGData(from: pasteboard) else { return nil } + + guard imageData.count <= sessionImageAttachmentMaxBytes else { + return .failure(L10n.string("Pasted image is too large. Limit images to \(byteCountString(sessionImageAttachmentMaxBytes)).")) + } + + return .success(WorkspaceChatAttachment.imagePNG(data: imageData)) +} + +private func pastedImagePNGData(from pasteboard: NSPasteboard) -> Data? { + if let pngData = pasteboard.data(forType: .png) { + return pngData + } + + if let tiffData = pasteboard.data(forType: .tiff), + let image = NSImage(data: tiffData) { + return pngData(from: image) + } + + if let image = NSImage(pasteboard: pasteboard) { + return pngData(from: image) + } + + let imageFileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: [ + .urlReadingFileURLsOnly: true + ]) as? [URL] ?? [] + + for url in imageFileURLs { + guard let image = NSImage(contentsOf: url), + let data = pngData(from: image) else { continue } + return data + } + + return nil +} + +private func pngData(from image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) else { + return nil + } + + return bitmap.representation(using: .png, properties: [:]) +} + +private func byteCountString(_ bytes: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) +} + private struct SessionPromptTextView: NSViewRepresentable { @Binding var text: String @@ -1229,6 +1582,8 @@ private struct SessionPromptTextView: NSViewRepresentable { let isFocused: FocusState.Binding let isDisabled: Bool let onCommandReturn: () -> Void + let onImagePaste: (WorkspaceChatAttachment) -> Void + let onImagePasteError: (String) -> Void func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() @@ -1241,6 +1596,8 @@ private struct SessionPromptTextView: NSViewRepresentable { let textView = PlaceholderCommandTextView() textView.placeholder = placeholder textView.commandReturnAction = onCommandReturn + textView.imagePasteAction = onImagePaste + textView.imagePasteErrorAction = onImagePasteError textView.delegate = context.coordinator textView.drawsBackground = false textView.isRichText = false @@ -1281,6 +1638,8 @@ private struct SessionPromptTextView: NSViewRepresentable { textView.placeholder = placeholder textView.commandReturnAction = onCommandReturn + textView.imagePasteAction = onImagePaste + textView.imagePasteErrorAction = onImagePasteError configure(textView) updateFocus(for: textView) textView.needsDisplay = true @@ -1341,6 +1700,8 @@ private final class PlaceholderCommandTextView: NSTextView { } var commandReturnAction: (() -> Void)? + var imagePasteAction: ((WorkspaceChatAttachment) -> Void)? + var imagePasteErrorAction: ((String) -> Void)? override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) @@ -1363,6 +1724,30 @@ private final class PlaceholderCommandTextView: NSTextView { super.keyDown(with: event) } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "v", + pastedImageAttachment(from: .general) != nil { + paste(nil) + return true + } + + return super.performKeyEquivalent(with: event) + } + + override func paste(_ sender: Any?) { + switch pastedImageAttachment(from: .general) { + case let .success(attachment): + imagePasteAction?(attachment) + return + case let .failure(message): + imagePasteErrorAction?(message) + return + case .none: + super.paste(sender) + } + } } private struct SessionPromptCardView: View { @@ -1720,7 +2105,7 @@ private struct PendingBubble: View { let tint: Color var body: some View { - TranscriptMessageSurface(tint: tint) { + ChatMessageSurface(tint: tint) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: icon) @@ -1741,7 +2126,7 @@ private struct PendingBubble: View { } } -private struct TranscriptMessageSurface: View { +private struct ChatMessageSurface: View { let tint: Color let content: Content @@ -1796,9 +2181,30 @@ private struct ConversationMessageCard: View { @Binding var isShowingMetadata: Bool var body: some View { - TranscriptMessageSurface(tint: roleTint) { + HStack(alignment: .top, spacing: 0) { + if isUserMessage { + Spacer(minLength: 80) + } + + messageSurface + .frame( + maxWidth: isUserMessage ? 680 : .infinity, + alignment: isUserMessage ? .trailing : .leading + ) + + if !isUserMessage { + Spacer(minLength: 40) + } + } + .frame(maxWidth: .infinity, alignment: isUserMessage ? .trailing : .leading) + } + + private var messageSurface: some View { + ChatMessageSurface(tint: roleTint) { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 10) { + RoleAvatar(role: message.role, tint: roleTint) + HermesBadge( text: displayRole, tint: roleTint, @@ -1844,6 +2250,10 @@ private struct ConversationMessageCard: View { } } + private var isUserMessage: Bool { + message.role == .user + } + private var displayRole: String { message.role.displayTitle } @@ -1864,7 +2274,7 @@ private struct ConversationMessageCard: View { private var roleSystemImage: String? { switch message.role { case .assistant: - return "sparkles" + return nil case .user: return "person.fill" case .system: @@ -1875,6 +2285,73 @@ private struct ConversationMessageCard: View { } } +private enum HermesDesktopResourceBundle { + static let module: Bundle? = { + let bundleName = "HermesDesktop_HermesDesktop.bundle" + let candidates: [URL?] = [ + Bundle.main.bundleURL.appendingPathComponent(bundleName), + Bundle.main.resourceURL?.appendingPathComponent(bundleName), + ] + + for candidate in candidates { + guard let candidate, let bundle = Bundle(url: candidate) else { continue } + return bundle + } + + return nil + }() +} + +private struct RoleAvatar: View { + let role: SessionMessageRole + let tint: Color + + var body: some View { + ZStack { + Circle() + .fill(tint.opacity(0.16)) + + avatarContent + } + .frame(width: 28, height: 28) + .overlay { + Circle() + .strokeBorder(tint.opacity(0.45), lineWidth: 1) + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var avatarContent: some View { + switch role { + case .assistant: + if let bundle = HermesDesktopResourceBundle.module, + bundle.url(forResource: "CaelProfile", withExtension: "png") != nil { + Image("CaelProfile", bundle: bundle) + .resizable() + .scaledToFill() + .clipShape(Circle()) + } else { + Image(systemName: "sparkles") + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + } + case .user: + Image(systemName: "person.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + case .system: + Image(systemName: "gearshape.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + case .event, .custom: + Image(systemName: "ellipsis") + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + } + } +} + private struct ToolMessageCard: View { let message: SessionMessageDisplay @Binding var isShowingMetadata: Bool diff --git a/Sources/HermesDesktop/Views/Sessions/SessionsView.swift b/Sources/HermesDesktop/Views/Sessions/SessionsView.swift index b84299b..8b59ca1 100644 --- a/Sources/HermesDesktop/Views/Sessions/SessionsView.swift +++ b/Sources/HermesDesktop/Views/Sessions/SessionsView.swift @@ -36,11 +36,18 @@ struct SessionsView: View { session: selectedSession, messages: appState.sessionMessageDisplays, errorMessage: appState.sessionsError, + conversationError: appState.sessionConversationError, + isSendingMessage: appState.isSendingSessionMessage, isDeletingSession: selectedSession.map { selectedSession in appState.isDeletingSession && appState.selectedSessionID == selectedSession.id } ?? false, isSessionPinned: selectedSession.map { appState.isSessionPinned($0.id) } ?? false, sessionCompactionNotice: appState.sessionCompactionNotice, + activeRun: appState.workspaceSessionActiveRun, + pendingTurn: appState.pendingSessionTurn, + liveMessages: appState.liveSessionMessageDisplays, + liveToolActivityCards: appState.liveToolActivityCards, + promptCards: appState.sessionPromptCards, mode: appState.selectedSessionDetailMode, terminal: appState.sessionTUITerminal, terminalTheme: appState.connectionStore.terminalTheme, @@ -67,6 +74,15 @@ struct SessionsView: View { onStartChat: { appState.startSelectedSessionChat() }, + onStartSession: { prompt, autoApproveCommands, attachments in + await appState.startNewSession(with: prompt, autoApproveCommands: autoApproveCommands, attachments: attachments) + }, + onSendMessage: { prompt, autoApproveCommands, attachments in + await appState.sendMessageToSelectedSession(prompt, autoApproveCommands: autoApproveCommands, attachments: attachments) + }, + onRespondToPrompt: { card, response in + await appState.respondToSessionPrompt(card, response: response) + }, onUpdateTerminalTheme: { newValue in appState.connectionStore.terminalTheme = newValue }, @@ -171,7 +187,7 @@ struct SessionsView: View { } else { HermesSurfacePanel( title: panelTitle, - subtitle: "Select a session to inspect its transcript, metadata and last activity." + subtitle: "Select a session to continue the chat and inspect its metadata and last activity." ) { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { diff --git a/Sources/HermesDesktop/Views/Shared/HermesUI.swift b/Sources/HermesDesktop/Views/Shared/HermesUI.swift index 37bc057..6cd0c07 100644 --- a/Sources/HermesDesktop/Views/Shared/HermesUI.swift +++ b/Sources/HermesDesktop/Views/Shared/HermesUI.swift @@ -7,28 +7,41 @@ enum HermesTheme { static let insetCornerRadius: CGFloat = 10 static let rowCornerRadius: CGFloat = 12 + static let background = Color(red: 0.008, green: 0.031, blue: 0.071) + static let sidebar = Color(red: 0.024, green: 0.075, blue: 0.149) + static let panel = Color(red: 0.027, green: 0.102, blue: 0.180) + static let card = Color(red: 0.039, green: 0.129, blue: 0.220) + static let row = Color(red: 0.051, green: 0.161, blue: 0.271) + static let text = Color(red: 0.918, green: 0.969, blue: 1.000) + static let mutedText = Color(red: 0.706, green: 0.835, blue: 0.902) + static let accent = Color(red: 0.337, green: 0.851, blue: 1.000) + static let accentSecondary = Color(red: 0.914, green: 0.727, blue: 0.365) + static let success = Color(red: 0.290, green: 0.871, blue: 0.502) + static let warning = Color(red: 0.965, green: 0.769, blue: 0.325) + static let danger = Color(red: 0.984, green: 0.443, blue: 0.522) + static var panelFill: Color { - Color(NSColor.controlBackgroundColor).opacity(0.72) + panel.opacity(0.94) } static var insetFill: Color { - Color.secondary.opacity(0.055) + card.opacity(0.72) } static var rowFill: Color { - Color.secondary.opacity(0.045) + row.opacity(0.62) } static var subtleStroke: Color { - Color.primary.opacity(0.055) + accent.opacity(0.15) } static var selectedFill: Color { - Color.accentColor.opacity(0.12) + accent.opacity(0.14) } static var selectedStroke: Color { - Color.accentColor.opacity(0.22) + accent.opacity(0.34) } } @@ -75,6 +88,9 @@ struct HermesPageContainer: View { .padding(.vertical, verticalPadding) .frame(maxWidth: .infinity, alignment: .top) } + .scrollContentBackground(.hidden) + .background(HermesTheme.background) + .foregroundStyle(HermesTheme.text) } } @@ -116,12 +132,13 @@ struct HermesPageHeader: View { Text(L10n.string(title)) .font(.title) .fontWeight(.semibold) + .foregroundStyle(HermesTheme.text) .lineLimit(1) .minimumScaleFactor(0.82) Text(L10n.string(subtitle)) .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(HermesTheme.mutedText) .fixedSize(horizontal: false, vertical: true) } } @@ -269,12 +286,13 @@ struct HermesSurfacePanel: View { if let title { Text(L10n.string(title)) .font(.headline) + .foregroundStyle(HermesTheme.text) } if let subtitle { Text(L10n.string(subtitle)) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(HermesTheme.mutedText) .fixedSize(horizontal: false, vertical: true) } } diff --git a/Sources/HermesDesktop/Views/Skills/SkillsView.swift b/Sources/HermesDesktop/Views/Skills/SkillsView.swift index b6d19e9..9702140 100644 --- a/Sources/HermesDesktop/Views/Skills/SkillsView.swift +++ b/Sources/HermesDesktop/Views/Skills/SkillsView.swift @@ -4,16 +4,23 @@ struct SkillsView: View { @EnvironmentObject private var appState: AppState @Binding var splitLayout: HermesSplitLayout @State private var searchText = "" + @State private var libraryTab: SkillLibraryTab = .installed @State private var editorMode: SkillEditorMode? @State private var editorDraft = SkillDraft() @State private var rawMarkdownContent = "" + @State private var workspaceSkills: [WorkspaceSkillItem] = [] + @State private var hubSkills: [WorkspaceSkillHubItem] = [] + @State private var sharedSkillsError: String? + @State private var sharedSkillsWarning: String? + @State private var isLoadingSharedSkills = false + @State private var actionSkillID: String? var body: some View { HermesCollapsibleHSplitView(layout: $splitLayout, detailMinWidth: 420) { VStack(alignment: .leading, spacing: 18) { HermesPageHeader( title: "Skills", - subtitle: "Browse the Hermes skill library discovered on the active host." + subtitle: "Browse installed skills, featured skills, and marketplace search from the shared Workspace API." ) { HermesExpandableSearchField( text: $searchText, @@ -40,11 +47,24 @@ struct SkillsView: View { await appState.loadSkills(reset: true) } } + .task(id: sharedSkillsReloadID) { + await loadSharedSkillsIfNeeded(force: false) + } } @ViewBuilder private var skillsContent: some View { - skillsPanel + switch libraryTab { + case .installed: + skillsPanel + case .featured: + workspaceSkillsPanel( + title: "Featured Skills", + subtitle: "Featured skills are loaded from the same Workspace API used by the web app." + ) + case .marketplace: + marketplacePanel + } } @ViewBuilder @@ -113,12 +133,151 @@ struct SkillsView: View { } } + @ViewBuilder + private var workspaceSkillsPanel: some View { + workspaceSkillsPanel( + title: "Workspace Skills", + subtitle: "Skills returned by the shared Workspace API." + ) + } + + private func workspaceSkillsPanel(title: String, subtitle: String) -> some View { + HermesSurfacePanel(title: title, subtitle: subtitle) { + sharedSkillsListContent + } + .overlay(alignment: .topTrailing) { + if isLoadingSharedSkills && (!workspaceSkills.isEmpty || !hubSkills.isEmpty) { + HermesLoadingOverlay() + .padding(18) + } + } + } + + @ViewBuilder + private var marketplacePanel: some View { + HermesSurfacePanel( + title: "Marketplace Search", + subtitle: "Search the skills hub through the server-side Workspace route. Install actions stay server-gated." + ) { + sharedSkillsListContent + } + .overlay(alignment: .topTrailing) { + if isLoadingSharedSkills && !hubSkills.isEmpty { + HermesLoadingOverlay() + .padding(18) + } + } + } + + @ViewBuilder + private var sharedSkillsListContent: some View { + if isLoadingSharedSkills && workspaceSkills.isEmpty && hubSkills.isEmpty { + HermesLoadingState(label: "Loading skills…", minHeight: 300) + } else if let sharedSkillsError, workspaceSkills.isEmpty && hubSkills.isEmpty { + ContentUnavailableView( + "Unable to load Workspace skills", + systemImage: "exclamationmark.triangle", + description: Text(sharedSkillsError) + ) + .frame(maxWidth: .infinity, minHeight: 300) + } else if libraryTab == .marketplace { + if hubSkills.isEmpty { + ContentUnavailableView( + "No marketplace results", + systemImage: "shippingbox", + description: Text(sharedSkillsWarning ?? "Try another marketplace search.") + ) + .frame(maxWidth: .infinity, minHeight: 300) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if let sharedSkillsWarning { + Text(sharedSkillsWarning) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + ForEach(hubSkills, id: \.resolvedIdentifier) { skill in + WorkspaceHubSkillCardRow( + skill: skill, + actionSkillID: actionSkillID, + onInstall: { + await runSharedSkillAction( + action: "install", + identifier: skill.resolvedIdentifier, + category: skill.category + ) + } + ) + } + } + } + } + } else if workspaceSkills.isEmpty { + ContentUnavailableView( + "No featured skills", + systemImage: "star", + description: Text("The Workspace API did not return any featured skills for the current filters.") + ) + .frame(maxWidth: .infinity, minHeight: 300) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(workspaceSkills) { skill in + WorkspaceSkillCardRow( + skill: skill, + actionSkillID: actionSkillID, + onInstall: { + await runSharedSkillAction( + action: "install", + identifier: skill.id, + category: skill.category + ) + }, + onToggle: { + await runSharedSkillAction( + action: "toggle", + identifier: skill.id, + enabled: !skill.isEnabled + ) + }, + onUninstall: { + await runSharedSkillAction( + action: "uninstall", + identifier: skill.id + ) + } + ) + } + } + } + } + } + private var skillsToolbar: some View { HStack(spacing: 10) { - HermesCreateActionButton("New Skill") { - startCreating() + Picker("Skills view", selection: $libraryTab) { + ForEach(SkillLibraryTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 330) + + if libraryTab == .installed { + HermesCreateActionButton("New Skill") { + startCreating() + } + .disabled(appState.isSavingSkillDraft || appState.isLoadingSkills) + } else { + Button { + Task { await loadSharedSkillsIfNeeded(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(isLoadingSharedSkills) } - .disabled(appState.isSavingSkillDraft || appState.isLoadingSkills) } .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity, alignment: .leading) @@ -144,6 +303,10 @@ struct SkillsView: View { return appState.skills.first(where: { $0.id == selectedSkillID }) } + private var sharedSkillsReloadID: String { + "\(appState.activeConnectionID?.uuidString ?? "none")|\(libraryTab.rawValue)|\(searchText)" + } + @ViewBuilder private var detailContent: some View { if let editorMode { @@ -176,6 +339,82 @@ struct SkillsView: View { ) } } + + private func loadSharedSkillsIfNeeded(force: Bool) async { + guard libraryTab != .installed else { return } + guard let connection = appState.activeConnection else { return } + if isLoadingSharedSkills && !force { return } + + isLoadingSharedSkills = true + sharedSkillsError = nil + sharedSkillsWarning = nil + + do { + switch libraryTab { + case .installed: + break + case .featured: + let response = try await appState.caelWorkspaceAPIService.loadWorkspaceSkills( + connection: connection, + tab: "featured", + search: searchText, + limit: 30 + ) + workspaceSkills = response.skills + hubSkills = [] + sharedSkillsError = response.error + case .marketplace: + let response = try await appState.caelWorkspaceAPIService.searchWorkspaceSkillsHub( + connection: connection, + query: searchText, + limit: 20 + ) + workspaceSkills = [] + hubSkills = response.results + sharedSkillsWarning = response.warning + sharedSkillsError = response.error + } + } catch { + workspaceSkills = [] + hubSkills = [] + sharedSkillsError = error.localizedDescription + } + + isLoadingSharedSkills = false + } + + private func runSharedSkillAction( + action: String, + identifier: String, + enabled: Bool? = nil, + category: String? = nil + ) async { + guard let connection = appState.activeConnection else { return } + actionSkillID = identifier + sharedSkillsError = nil + + do { + _ = try await appState.caelWorkspaceAPIService.runWorkspaceSkillAction( + connection: connection, + action: action, + identifier: identifier, + enabled: enabled, + category: category + ) + await appState.loadSkills(reset: true) + await loadSharedSkillsIfNeeded(force: true) + } catch { + let message = error.localizedDescription + sharedSkillsError = message + appState.activeAlert = AppAlert( + title: "Skill action failed", + message: message + ) + } + + actionSkillID = nil + } + private func startCreating() { var draft = SkillDraft() draft.refreshSuggestedSlug() @@ -216,6 +455,162 @@ struct SkillsView: View { } } +private enum SkillLibraryTab: String, CaseIterable, Identifiable { + case installed + case featured + case marketplace + + var id: String { rawValue } + + var title: String { + switch self { + case .installed: + return "Installed" + case .featured: + return "Featured" + case .marketplace: + return "Marketplace" + } + } +} + +private struct WorkspaceSkillCardRow: View { + let skill: WorkspaceSkillItem + let actionSkillID: String? + let onInstall: () async -> Void + let onToggle: () async -> Void + let onUninstall: () async -> Void + + private var isOperating: Bool { actionSkillID == skill.id } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(skill.resolvedName) + .font(.headline) + if let description = skill.resolvedDescription { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + + Spacer(minLength: 12) + + VStack(alignment: .trailing, spacing: 6) { + HermesBadge(text: skill.resolvedCategory, tint: .secondary) + HermesBadge(text: skill.isInstalled ? (skill.isEnabled ? "Enabled" : "Disabled") : "Not installed", tint: skill.isEnabled ? .green : .secondary) + } + } + + HStack(spacing: 8) { + if skill.isInstalled { + Button(skill.isEnabled ? "Disable" : "Enable") { + Task { await onToggle() } + } + .buttonStyle(.bordered) + + Button("Uninstall") { + Task { await onUninstall() } + } + .buttonStyle(.bordered) + .disabled(skill.isBuiltin) + } else { + Button("Install") { + Task { await onInstall() } + } + .buttonStyle(.borderedProminent) + } + + Spacer() + + Text(skill.resolvedOrigin) + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(isOperating) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + } + } +} + +private struct WorkspaceHubSkillCardRow: View { + let skill: WorkspaceSkillHubItem + let actionSkillID: String? + let onInstall: () async -> Void + + private var isOperating: Bool { actionSkillID == skill.resolvedIdentifier } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(skill.resolvedName) + .font(.headline) + if let description = skill.resolvedDescription { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + + Spacer(minLength: 12) + + VStack(alignment: .trailing, spacing: 6) { + HermesBadge(text: skill.resolvedSource, tint: .secondary) + if skill.isInstalled { + HermesBadge(text: "Installed", tint: .green) + } + } + } + + HStack(spacing: 8) { + if skill.isInstalled { + Button("Installed") {} + .buttonStyle(.bordered) + .disabled(true) + } else { + Button("Install") { + Task { await onInstall() } + } + .buttonStyle(.borderedProminent) + .disabled(isOperating) + } + + Spacer() + + Text(skill.resolvedIdentifier) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + } + } +} + + private struct SkillCardRow: View { let skill: SkillSummary let isSelected: Bool diff --git a/Sources/HermesDesktop/Views/Terminal/TerminalWorkspaceView.swift b/Sources/HermesDesktop/Views/Terminal/TerminalWorkspaceView.swift index 6da0b17..1bf43e4 100644 --- a/Sources/HermesDesktop/Views/Terminal/TerminalWorkspaceView.swift +++ b/Sources/HermesDesktop/Views/Terminal/TerminalWorkspaceView.swift @@ -1,11 +1,20 @@ import SwiftUI struct TerminalWorkspaceView: View { + @EnvironmentObject private var appState: AppState @ObservedObject var workspace: TerminalWorkspaceStore let context: TerminalWorkspaceContext let ensureTerminalSession: () -> Void let updateTerminalTheme: (TerminalThemePreference) -> Void @State private var isShowingAppearanceEditor = false + @State private var isShowingAttachSheet = false + @State private var attachSessionID = "" + @State private var availableTerminalSessions: [WorkspaceTerminalSessionSummary] = [] + @State private var isLoadingTerminalSessions = false + @State private var terminalSessionsError: String? + @State private var terminalSessionRenameTarget: WorkspaceTerminalSessionSummary? + @State private var terminalSessionRenameValue = "" + @State private var isRenamingTerminalSession = false private let tabStripHeight: CGFloat = 44 var body: some View { @@ -18,6 +27,8 @@ struct TerminalWorkspaceView: View { TerminalTabChip( profileName: tab.session.connection.resolvedHermesProfileName, hostLabel: tab.session.connection.label, + modeLabel: tab.session.backendLabel, + isWorkspacePTY: tab.session.isWorkspacePTY, isSelected: workspace.selectedTabID == tab.id, isCurrentWorkspace: isTabForActiveWorkspace(tab), onSelect: { requestTabSelection(tab.id) }, @@ -40,6 +51,23 @@ struct TerminalWorkspaceView: View { Label(L10n.string("New Tab"), systemImage: "plus") } .buttonStyle(.borderedProminent) + + Button { + requestSharedTab(for: activeConnection) + } label: { + Label(L10n.string("Shared PTY"), systemImage: "network") + } + .buttonStyle(.bordered) + .help(L10n.string("Open a Workspace-backed terminal session that can share the web/mobile terminal contract.")) + + Button { + attachSessionID = "" + isShowingAttachSheet = true + } label: { + Label(L10n.string("Attach PTY"), systemImage: "link") + } + .buttonStyle(.bordered) + .help(L10n.string("Attach to an existing Workspace terminal session ID from web or mobile.")) } Spacer(minLength: 8) @@ -67,7 +95,7 @@ struct TerminalWorkspaceView: View { ContentUnavailableView( L10n.string("No terminal tab"), systemImage: "terminal", - description: Text(L10n.string("Create a tab to start a real SSH shell for the active host.")) + description: Text(L10n.string("Create a native SSH tab or a shared Workspace PTY tab for the active host.")) ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -83,6 +111,190 @@ struct TerminalWorkspaceView: View { ensureTerminalSession() } } + .sheet(isPresented: $isShowingAttachSheet) { + attachWorkspacePTYSheet + } + } + + private var attachWorkspacePTYSheet: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("Attach Shared PTY")) + .font(.title3.weight(.semibold)) + Text(L10n.string("Attach to a live Workspace terminal session from web or mobile, or paste a session ID manually.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button { + Task { await loadAvailableTerminalSessions() } + } label: { + Label(L10n.string("Refresh"), systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(isLoadingTerminalSessions || context.activeConnection == nil) + } + + terminalSessionPicker + + if let terminalSessionRenameTarget { + terminalSessionRenamePanel(terminalSessionRenameTarget) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Manual session ID")) + .font(.headline) + TextField(L10n.string("Session ID"), text: $attachSessionID) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .onSubmit { attachWorkspacePTY() } + } + + HStack { + Spacer() + Button(L10n.string("Cancel")) { + isShowingAttachSheet = false + } + Button(L10n.string("Attach")) { + attachWorkspacePTY() + } + .buttonStyle(.borderedProminent) + .disabled(attachSessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || context.activeConnection == nil) + } + } + .padding(20) + .frame(width: 620, height: 520) + .task { + await loadAvailableTerminalSessions() + } + } + + private var terminalSessionPicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("Live Workspace sessions")) + .font(.headline) + + if isLoadingTerminalSessions { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(L10n.string("Loading sessions...")) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 140, alignment: .center) + } else if let terminalSessionsError { + ContentUnavailableView( + L10n.string("Could not load sessions"), + systemImage: "exclamationmark.triangle", + description: Text(terminalSessionsError) + ) + .frame(maxWidth: .infinity, minHeight: 140) + } else if availableTerminalSessions.isEmpty { + ContentUnavailableView( + L10n.string("No live Workspace PTYs"), + systemImage: "terminal", + description: Text(L10n.string("Open a Shared PTY tab in desktop or web, then refresh this list.")) + ) + .frame(maxWidth: .infinity, minHeight: 140) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(availableTerminalSessions) { session in + terminalSessionRow(session) + } + } + .padding(.vertical, 2) + } + .frame(maxHeight: 210) + } + } + } + + private func terminalSessionRow(_ session: WorkspaceTerminalSessionSummary) -> some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "terminal") + .foregroundStyle(.tint) + .frame(width: 22) + + VStack(alignment: .leading, spacing: 4) { + Text(terminalSessionTitle(session)) + .font(.system(.body, design: terminalSessionHasLabel(session) ? .default : .monospaced).weight(.semibold)) + .lineLimit(1) + + Text(terminalSessionSubtitle(session)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 12) + + Text(terminalSessionCreatedLabel(session)) + .font(.caption) + .foregroundStyle(.secondary) + + Button(L10n.string("Attach")) { + attachWorkspacePTY(sessionID: session.id) + } + .buttonStyle(.borderedProminent) + + Button { + terminalSessionRenameTarget = session + terminalSessionRenameValue = terminalSessionTitle(session) + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.bordered) + .help(L10n.string("Rename this shared Workspace PTY label.")) + } + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func terminalSessionRenamePanel(_ session: WorkspaceTerminalSessionSummary) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.string("Rename Shared PTY")) + .font(.headline) + Text(shortTerminalSessionID(session.id)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + terminalSessionRenameTarget = nil + terminalSessionRenameValue = "" + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + + HStack(spacing: 8) { + TextField(L10n.string("Terminal label"), text: $terminalSessionRenameValue) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await renameWorkspacePTY(session) } } + + Button(L10n.string("Save")) { + Task { await renameWorkspacePTY(session) } + } + .buttonStyle(.borderedProminent) + .disabled(isRenamingTerminalSession || terminalSessionRenameValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(12) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.accentColor.opacity(0.22), lineWidth: 1) + } } private var terminalAppearance: TerminalThemeAppearance { @@ -108,6 +320,90 @@ struct TerminalWorkspaceView: View { } } + private func requestSharedTab(for connection: ConnectionProfile) { + DispatchQueue.main.async { + workspace.addWorkspaceTerminalTab(for: connection.updated()) + } + } + + private func loadAvailableTerminalSessions() async { + guard let connection = context.activeConnection else { return } + isLoadingTerminalSessions = true + terminalSessionsError = nil + do { + let response = try await appState.caelWorkspaceAPIService.loadWorkspaceTerminalSessions(connection: connection) + availableTerminalSessions = response.sessions + terminalSessionsError = response.ok == false ? response.error : nil + } catch { + availableTerminalSessions = [] + terminalSessionsError = error.localizedDescription + } + isLoadingTerminalSessions = false + } + + private func attachWorkspacePTY() { + let sessionID = attachSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + attachWorkspacePTY(sessionID: sessionID) + } + + private func attachWorkspacePTY(sessionID: String) { + guard let connection = context.activeConnection else { return } + guard !sessionID.isEmpty else { return } + DispatchQueue.main.async { + workspace.addWorkspaceTerminalTab(for: connection.updated(), sessionId: sessionID) + isShowingAttachSheet = false + attachSessionID = "" + } + } + + private func renameWorkspacePTY(_ session: WorkspaceTerminalSessionSummary) async { + guard let connection = context.activeConnection else { return } + let label = terminalSessionRenameValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty else { return } + isRenamingTerminalSession = true + terminalSessionsError = nil + do { + _ = try await appState.caelWorkspaceAPIService.renameWorkspaceTerminalSession( + connection: connection, + sessionID: session.id, + label: label + ) + terminalSessionRenameTarget = nil + terminalSessionRenameValue = "" + await loadAvailableTerminalSessions() + } catch { + terminalSessionsError = error.localizedDescription + } + isRenamingTerminalSession = false + } + + private func shortTerminalSessionID(_ sessionID: String) -> String { + if sessionID.count <= 12 { return sessionID } + return String(sessionID.prefix(8)) + "..." + String(sessionID.suffix(4)) + } + + private func terminalSessionTitle(_ session: WorkspaceTerminalSessionSummary) -> String { + let label = session.label?.trimmingCharacters(in: .whitespacesAndNewlines) + return label?.isEmpty == false ? (label ?? "") : shortTerminalSessionID(session.id) + } + + private func terminalSessionHasLabel(_ session: WorkspaceTerminalSessionSummary) -> Bool { + let label = session.label?.trimmingCharacters(in: .whitespacesAndNewlines) + return label?.isEmpty == false + } + + private func terminalSessionSubtitle(_ session: WorkspaceTerminalSessionSummary) -> String { + let command = session.command.joined(separator: " ") + let cwd = session.cwd ?? "unknown cwd" + let size = "\(session.cols ?? 0)x\(session.rows ?? 0)" + return "\(shortTerminalSessionID(session.id)) - \(command.isEmpty ? "shell" : command) - \(cwd) - \(size)" + } + + private func terminalSessionCreatedLabel(_ session: WorkspaceTerminalSessionSummary) -> String { + let date = Date(timeIntervalSince1970: session.createdAt / 1000) + return date.formatted(date: .omitted, time: .shortened) + } + private func requestTabSelection(_ tabID: UUID) { DispatchQueue.main.async { workspace.selectTab(tabID) @@ -124,6 +420,8 @@ struct TerminalWorkspaceView: View { private struct TerminalTabChip: View { let profileName: String let hostLabel: String + let modeLabel: String + let isWorkspacePTY: Bool let isSelected: Bool let isCurrentWorkspace: Bool let onSelect: () -> Void @@ -141,6 +439,8 @@ private struct TerminalTabChip: View { if !isCurrentWorkspace { HermesBadge(text: "Other Profile", tint: .orange) + } else if isWorkspacePTY { + HermesBadge(text: modeLabel, tint: .blue) } } diff --git a/Sources/HermesDesktop/Views/Usage/UsageView.swift b/Sources/HermesDesktop/Views/Usage/UsageView.swift index 9b3a0af..59e76ef 100644 --- a/Sources/HermesDesktop/Views/Usage/UsageView.swift +++ b/Sources/HermesDesktop/Views/Usage/UsageView.swift @@ -10,9 +10,11 @@ struct UsageView: View { VStack(alignment: .leading, spacing: 24) { HermesPageHeader( title: "Usage", - subtitle: "The main cards and charts show input/output tokens for the active Hermes profile. When more than one profile is discovered, the host-wide panel shows all-categories tokens across readable profiles." + subtitle: "The main cards and charts show input/output tokens for the active Hermes profile. Provider Remaining Limits uses the shared Cael Workspace usage snapshot inspired by CodexBar." ) + CaelProviderLimitsWebPanel() + usageContent } .overlay(alignment: .topTrailing) { diff --git a/Tests/HermesDesktopTests/AppSectionTests.swift b/Tests/HermesDesktopTests/AppSectionTests.swift index 6d9c3d3..a8e4b2a 100644 --- a/Tests/HermesDesktopTests/AppSectionTests.swift +++ b/Tests/HermesDesktopTests/AppSectionTests.swift @@ -16,4 +16,25 @@ struct AppSectionTests { #expect(AppSection.skills.navigationShortcutKey == "9") #expect(AppSection.terminal.navigationShortcutKey == "0") } + + @Test + func commandCenterMirrorSectionsDoNotClaimNumberShortcuts() { + let mirrorSections: [AppSection] = [ + .mail, + .contacts, + .calendar, + .missionControl, + .operations, + .swarm, + .memory, + .integrations, + .mcp, + .profiles + ] + + for section in mirrorSections { + #expect(section.isCommandCenterMirrorSection) + #expect(section.navigationShortcutKey == nil) + } + } } diff --git a/Tests/HermesDesktopTests/AppStateChatExperienceTests.swift b/Tests/HermesDesktopTests/AppStateChatExperienceTests.swift new file mode 100644 index 0000000..60bc675 --- /dev/null +++ b/Tests/HermesDesktopTests/AppStateChatExperienceTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import HermesDesktop + +@MainActor +struct AppStateChatExperienceTests { + @Test + func chatModeDoesNotLaunchEmbeddedTUIForNewSession() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let state = AppState(paths: makeTestAppPaths(root: root)) + let connection = ConnectionProfile(label: "Host", sshHost: "example.local").updated() + state.connectionStore.upsert(connection) + state.activeConnectionID = connection.id + + state.startNewSessionChat() + + #expect(state.selectedSessionDetailMode == .chat) + #expect(state.selectedSessionID == nil) + #expect(state.sessionTUITerminal == nil) + } + + @Test + func chatModeDoesNotLaunchEmbeddedTUIForExistingSession() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let state = AppState(paths: makeTestAppPaths(root: root)) + let connection = ConnectionProfile(label: "Host", sshHost: "example.local").updated() + state.connectionStore.upsert(connection) + state.activeConnectionID = connection.id + state.selectedSessionID = "session-123" + + state.setSessionDetailMode(.chat) + + #expect(state.selectedSessionDetailMode == .chat) + #expect(state.selectedSessionID == "session-123") + #expect(state.sessionTUITerminal == nil) + } + + @Test + func startingNewChatClearsPreviousNativeTurnCards() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let state = AppState(paths: makeTestAppPaths(root: root)) + let connection = ConnectionProfile(label: "Host", sshHost: "example.local").updated() + state.connectionStore.upsert(connection) + state.activeConnectionID = connection.id + state.selectedSessionID = "session-123" + state.liveSessionMessageDisplays = [ + SessionMessageDisplay(id: "live-user", role: .user, content: "hello") + ] + state.liveToolActivityCards = [ + HermesToolActivityCard( + id: "tool-1", + title: "terminal", + status: "running", + detail: "swift build", + isRunning: true, + updatedAt: Date(timeIntervalSince1970: 0) + ) + ] + state.sessionPromptCards = [ + HermesPromptCard( + id: "approval-1", + sessionID: "session-123", + requestID: "approval-1", + kind: .approval, + title: "Approve command", + message: "Run swift build?", + choices: ["Approve", "Deny"] + ) + ] + + state.startNewSessionChat() + + #expect(state.selectedSessionDetailMode == .chat) + #expect(state.selectedSessionID == nil) + #expect(state.liveSessionMessageDisplays.isEmpty) + #expect(state.liveToolActivityCards.isEmpty) + #expect(state.sessionPromptCards.isEmpty) + #expect(state.sessionTUITerminal == nil) + } +} diff --git a/Tests/HermesDesktopTests/CaelCommandCenterContractTests.swift b/Tests/HermesDesktopTests/CaelCommandCenterContractTests.swift new file mode 100644 index 0000000..dededf5 --- /dev/null +++ b/Tests/HermesDesktopTests/CaelCommandCenterContractTests.swift @@ -0,0 +1,172 @@ +import Foundation +import Testing + +@testable import HermesDesktop + +struct CaelCommandCenterContractTests { + @Test + func statusDecodesSharedCommandCenterContract() throws { + let json = #""" + { + "ok": true, + "generatedAt": "2026-05-24T00:00:00.000Z", + "host": "BigMac", + "posture": { + "bind": "100.97.216.111:3077", + "remoteAccess": "Tailscale-only browser/PWA access", + "auth": "enabled", + "publicInternet": "disabled" + }, + "services": [], + "links": [], + "contract": { + "id": "cael-command-center", + "version": "2026-05-24.si-004", + "generatedAt": "2026-05-24T00:00:00.000Z", + "principle": "The shared Cael Workspace API contract owns business logic and state; Swift/Desktop and Web/PWA are clients, not separate sources of truth.", + "primarySurface": "Cael Desktop native macOS command center", + "mirrorSurface": "Cael Workspace :3077 responsive web/PWA mirror for iPhone/iPad and browsers", + "privateAccess": "Tailscale-only/private mesh; no public internet exposure by default.", + "surfaces": [ + { + "id": "kb-brain-dashboard", + "label": "KB Brain Dashboard", + "owner": "legacy", + "desktop": "planned", + "web": "retired", + "source": "/Users/cderamos/projects/KB_Brain_Dashboard", + "status": "migration-only", + "description": "Absorb useful surfaces into Cael." + } + ] + } + } + """# + + let status = try JSONDecoder().decode(CaelWorkspaceStatus.self, from: Data(json.utf8)) + + #expect(status.contract.id == "cael-command-center") + #expect(status.contract.primarySurface.contains("Desktop")) + #expect(status.contract.mirrorSurface.contains(":3077")) + #expect(status.contract.privateAccess.contains("Tailscale-only")) + #expect(status.contract.surfaces.first?.id == "kb-brain-dashboard") + #expect(status.contract.surfaces.first?.status == "migration-only") + #expect(status.contract.surfaces.first?.owner == "legacy") + } + + @Test + func decodesCommandCenterSummaryEnvelope() throws { + let json = #""" + { + "ok": true, + "generatedAt": "2026-05-24T00:00:00.000Z", + "source": "cael-workspace:3077", + "scope": "mixed", + "warnings": [], + "errors": [], + "links": [{ "label": "Usage", "href": "/usage", "kind": "local" }], + "data": { + "version": "2026-05-24.phase1", + "generatedAt": "2026-05-24T00:00:00.000Z", + "contract": null, + "posture": { + "host": "BigMac", + "bind": "100.97.216.111:3077", + "remoteAccess": "Tailscale-only", + "auth": "enabled", + "publicInternet": "disabled" + }, + "systems": [{ + "id": "workspace", + "label": "Workspace", + "ok": true, + "lane": "personal", + "owner": "Cael", + "detail": "HTTP 200", + "latencyMs": 12 + }], + "integrations": [], + "usage": { + "enabledProviders": ["codex"], + "providers": [{ + "id": "codex", + "label": "Codex", + "status": "ok", + "confidence": "live", + "monitorKind": "cael", + "caelDefault": true, + "caelModel": "gpt-5.5", + "primary": { + "label": "weekly", + "usedPercent": 25, + "remainingPercent": 75, + "resetsAt": null + } + }] + }, + "automations": { + "boundary": "separate lanes", + "instances": [{ + "id": "personal-bigmac", + "label": "Personal n8n", + "ok": true, + "scope": "personal", + "boundary": "personal only", + "failures": 0 + }] + }, + "brain": { + "sources": [{ + "id": "personal-kv", + "label": "Personal Knowledge Vault", + "category": "personal", + "status": "available", + "writable": false + }] + }, + "actionGates": [{ + "id": "business-dry-run-smoke", + "label": "Business dry run", + "source": "n8n-governance", + "status": "approval_gated", + "riskLevel": "production_mutation", + "approvalRequired": true, + "dryRunSupported": true, + "detail": "requires approval" + }], + "agentRuns": [{ + "id": "/Users/cderamos/.hermes/receipts/chat.md", + "title": "chat recovery receipt", + "status": "personal-bigmac", + "updatedAt": "2026-05-24T01:00:00.000Z", + "source": "receipt", + "path": "/Users/cderamos/.hermes/receipts/chat.md" + }], + "nowNext": [{ + "id": "runtime-posture", + "label": "Runtime ready", + "detail": "Core checks are online.", + "tone": "success", + "href": "/cael-home" + }], + "homebaseRecords": { + "status": "planned", + "detail": "Twenty remains legacy.", + "records": [] + } + } + } + """# + + let envelope = try JSONDecoder().decode(CaelCommandCenterSummaryEnvelope.self, from: Data(json.utf8)) + + #expect(envelope.ok) + #expect(envelope.source == "cael-workspace:3077") + #expect(envelope.data?.posture?.host == "BigMac") + #expect(envelope.data?.usage?.enabledProviders == ["codex"]) + #expect(envelope.data?.actionGates.first?.approvalRequired == true) + #expect(envelope.data?.brain?.sources.first?.status == "available") + #expect(envelope.links?.first?.kind == "local") + } + +} diff --git a/Tests/HermesDesktopTests/CaelCommandCenterSnapshotStoreTests.swift b/Tests/HermesDesktopTests/CaelCommandCenterSnapshotStoreTests.swift new file mode 100644 index 0000000..887078b --- /dev/null +++ b/Tests/HermesDesktopTests/CaelCommandCenterSnapshotStoreTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing + +@testable import HermesDesktop + +struct CaelCommandCenterSnapshotStoreTests { + @Test + func cacheIsScopedByWorkspaceURLAndStripsVaultSecretValues() throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent( + "HermesDesktopCacheTests-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: rootURL) } + + let paths = AppPaths( + fileManager: fileManager, + applicationSupportURL: rootURL.appendingPathComponent("Support", isDirectory: true), + controlSocketDirectoryURL: rootURL.appendingPathComponent("Sockets", isDirectory: true) + ) + let store = CaelCommandCenterSnapshotStore(paths: paths) + let profile = ConnectionProfile( + label: "BigMac", + sshAlias: "bigmac-home" + ).updated() + let alternateURLProfile = ConnectionProfile( + label: "BigMac", + sshAlias: "bigmac-home", + caelWorkspaceBaseURL: "http://127.0.0.1:3078" + ).updated() + + let sections = CaelCommandCenterSectionsSnapshot( + actionGates: nil, + agentRuns: nil, + automations: nil, + brain: nil, + homebaseRecords: nil, + memoryArtifacts: nil, + usageLimits: nil, + vaultRefs: CaelCommandCenterSectionEnvelope( + ok: true, + generatedAt: "2026-05-24T00:00:00.000Z", + source: "test", + scope: "personal", + data: CaelCommandCenterVaultRefsSection( + warningCount: 0, + refs: [ + CaelCommandCenterVaultRef( + id: "claude-token", + displayName: "Claude token", + scope: "personal", + exists: true, + lastVerifiedAt: nil, + rotationDueAt: nil, + linkedSystems: ["cael"], + vaultHref: "vaultwarden://item/claude-token", + secretValue: "do-not-store" + ) + ], + policy: ["refs only"] + ), + warnings: [], + errors: [] + ) + ) + + try store.save(summaryEnvelope: nil, sections: sections, for: profile) + + let loaded = store.load(for: profile) + #expect(loaded?.workspaceBaseURL == ConnectionProfile.defaultCaelWorkspaceBaseURL) + #expect(loaded?.sections?.vaultRefs?.data?.refs.first?.secretValue == nil) + #expect(loaded?.sections?.vaultRefs?.warnings.contains("Secret values were stripped before local cache storage.") == true) + #expect(store.load(for: alternateURLProfile)?.workspaceBaseURL == nil) + } +} diff --git a/Tests/HermesDesktopTests/ConnectionProfileTests.swift b/Tests/HermesDesktopTests/ConnectionProfileTests.swift index 5515e41..b5143d4 100644 --- a/Tests/HermesDesktopTests/ConnectionProfileTests.swift +++ b/Tests/HermesDesktopTests/ConnectionProfileTests.swift @@ -322,6 +322,53 @@ struct ConnectionProfileTests { #expect(result.stderr == "") } + + @Test + func caelWorkspaceBaseURLDefaultsToBigMacAndBuildsRoutes() { + let profile = ConnectionProfile( + label: "BigMac", + sshAlias: "bigmac-home" + ).updated() + + #expect(profile.resolvedCaelWorkspaceBaseURL == ConnectionProfile.defaultCaelWorkspaceBaseURL) + #expect(profile.caelWorkspaceURLString(path: "/cael-home") == "http://100.97.216.111:3077/cael-home") + #expect(profile.caelWorkspaceURLString(path: "usage") == "http://100.97.216.111:3077/usage") + } + + @Test + func caelWorkspaceBaseURLNormalizesAndScopesCommandCenterClient() { + let base = ConnectionProfile( + label: "BigMac", + sshAlias: "bigmac-home" + ).updated() + let custom = ConnectionProfile( + label: "BigMac", + sshAlias: "bigmac-home", + caelWorkspaceBaseURL: " https://cael.example.test:8443/workspace/ " + ).updated() + + #expect(custom.resolvedCaelWorkspaceBaseURL == "https://cael.example.test:8443/workspace") + #expect(custom.workspaceScopeFingerprint == base.workspaceScopeFingerprint) + #expect(custom.commandCenterClientFingerprint != base.commandCenterClientFingerprint) + } + + @Test + func rejectsInvalidCaelWorkspaceBaseURL() { + let missingScheme = ConnectionProfile( + label: "Bad Workspace", + sshHost: "example.com", + caelWorkspaceBaseURL: "100.97.216.111:3077" + ).updated() + let withQuery = ConnectionProfile( + label: "Bad Workspace", + sshHost: "example.com", + caelWorkspaceBaseURL: "http://100.97.216.111:3077?token=abc" + ).updated() + + #expect(missingScheme.validationError == "Workspace URL must start with http:// or https://.") + #expect(withQuery.validationError == "Workspace URL cannot include query strings or fragments.") + } + @Test func controlPathRecreatesTemporarySocketDirectoryWhenPruned() throws { let fileManager = FileManager.default diff --git a/docs/distribution.md b/docs/distribution.md index 911b203..fd35630 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -120,6 +120,31 @@ source and build the app yourself: That produces a local app bundle in `dist/HermesDesktop.app`. +For the Cael-local distribution loop on BigMac, the current private arm64 package +is served by Cael Workspace rather than GitHub: + +```bash +cd /Users/cderamos/StorageRuntime/hermes-desktop-cael +HERMES_MAC_ARCHS=arm64 ./scripts/build-macos-app.sh +ditto -c -k --keepParent dist/HermesDesktop.app /tmp/CaelDesktop-macOS-arm64.zip +cp /tmp/CaelDesktop-macOS-arm64.zip \ + /Users/cderamos/StorageRuntime/hermes-workspace-cael/public/downloads/CaelDesktop-macOS-arm64.zip +cd /Users/cderamos/StorageRuntime/hermes-workspace-cael/public/downloads +shasum -a 256 CaelDesktop-macOS-arm64.zip > CaelDesktop-macOS-arm64.sha256 +``` + +The private install page is `http://100.97.216.111:3077/desktop` on the +Tailscale mesh. It serves: + +- `/downloads/CaelDesktop-macOS-arm64.zip` +- `/downloads/CaelDesktop-macOS-arm64.sha256` + +Install paths used in the Cael setup: + +- BigMac source/build path: `/Users/cderamos/StorageRuntime/hermes-desktop-cael` +- BigMac installed app: `/Applications/HermesDesktop.app` +- MBP installed app: `~/Applications/HermesDesktop.app` or `/Applications/HermesDesktop.app` + This is still an ad-hoc signed, non-notarized bundle, because that is the current build and release model in the repo. Building locally does not turn it into a notarized distribution, but it does let you trust your own build inputs diff --git a/packaging/CaelImageGenMaster.png b/packaging/CaelImageGenMaster.png new file mode 100644 index 0000000..bbd39fa Binary files /dev/null and b/packaging/CaelImageGenMaster.png differ diff --git a/packaging/Info.plist b/packaging/Info.plist index 67c66d8..8f0a4a5 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Hermes Desktop + Cael Desktop CFBundleExecutable HermesDesktop CFBundleIconFile @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Hermes Desktop + Cael Desktop CFBundlePackageType APPL CFBundleShortVersionString @@ -30,6 +30,11 @@ NSHumanReadableCopyright Hermes Desktop contributors + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSSupportsAutomaticGraphicsSwitching NSPrincipalClass diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 0fcda03..dc6a452 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -111,7 +111,14 @@ generate_icon() { mkdir -p "$ICONSET_PATH" if [[ ! -f "$ICON_SOURCE" ]]; then - env "${BUILD_ENV[@]}" swift "$ROOT_DIR/scripts/generate-app-icon.swift" "$ICON_SOURCE" + if [[ -f "$ROOT_DIR/packaging/CaelImageGenMaster.png" && -f "$ROOT_DIR/../hermes-workspace-cael/scripts/generate-cael-brand-assets.swift" ]]; then + env "${BUILD_ENV[@]}" swift "$ROOT_DIR/../hermes-workspace-cael/scripts/generate-cael-brand-assets.swift" \ + "$ROOT_DIR/packaging/CaelImageGenMaster.png" \ + "$ROOT_DIR" \ + "$ROOT_DIR/../hermes-workspace-cael" + else + env "${BUILD_ENV[@]}" swift "$ROOT_DIR/scripts/generate-app-icon.swift" "$ICON_SOURCE" + fi fi sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_PATH/icon_16x16.png" >/dev/null @@ -239,11 +246,13 @@ if [[ ! -d "$APP_RESOURCE_BUNDLE_PATH" ]]; then echo "error: expected SwiftPM resource bundle not found at $APP_RESOURCE_BUNDLE_PATH" >&2 exit 1 fi +rm -rf "$RESOURCES_PATH/$APP_RESOURCE_BUNDLE_NAME" cp -R "$APP_RESOURCE_BUNDLE_PATH" "$RESOURCES_PATH/" if [[ -d "$LOCALIZATION_SOURCE_PATH" ]]; then find "$LOCALIZATION_SOURCE_PATH" -maxdepth 1 -name "*.lproj" -type d -exec cp -R {} "$RESOURCES_PATH/" \; fi verify_localization_resources +xattr -cr "$BUNDLE_PATH" codesign --force --deep --sign - "$BUNDLE_PATH" >/dev/null codesign --verify --deep --strict "$BUNDLE_PATH" >/dev/null