diff --git a/Sources/Halos/Models/LazuliEvent.swift b/Sources/Halos/Models/LazuliEvent.swift index b963686..eb61c9d 100644 --- a/Sources/Halos/Models/LazuliEvent.swift +++ b/Sources/Halos/Models/LazuliEvent.swift @@ -21,6 +21,7 @@ public enum MessageKind: Sendable { case assistant case user case system + case workSummary case tool case debug case error @@ -34,6 +35,49 @@ public enum MessageStreamState: String, Sendable { case aborted } +public enum VeyraApprovalDecision: String, Sendable { + case approve + case deny + case ask +} + +public struct VeyraApprovalCard: Equatable, Sendable { + public let approvalId: String + public let draftId: String + public let providerId: String + public let accountId: String? + public let status: String + public let riskLevel: String + public let kind: String + public let title: String? + public let body: String + public let createdAtText: String + + public init( + approvalId: String, + draftId: String, + providerId: String, + accountId: String?, + status: String, + riskLevel: String, + kind: String, + title: String?, + body: String, + createdAtText: String + ) { + self.approvalId = approvalId + self.draftId = draftId + self.providerId = providerId + self.accountId = accountId + self.status = status + self.riskLevel = riskLevel + self.kind = kind + self.title = title + self.body = body + self.createdAtText = createdAtText + } +} + public struct ActivityMessage: Identifiable, Equatable, Sendable { public let id: String public let kind: MessageKind @@ -44,6 +88,7 @@ public struct ActivityMessage: Identifiable, Equatable, Sendable { public let parentRunId: String? public let toolCallId: String? public let streamState: MessageStreamState + public let veyraApproval: VeyraApprovalCard? public init( id: String, @@ -54,7 +99,8 @@ public struct ActivityMessage: Identifiable, Equatable, Sendable { runId: String? = nil, parentRunId: String? = nil, toolCallId: String? = nil, - streamState: MessageStreamState = .none + streamState: MessageStreamState = .none, + veyraApproval: VeyraApprovalCard? = nil ) { self.id = id self.kind = kind @@ -65,10 +111,91 @@ public struct ActivityMessage: Identifiable, Equatable, Sendable { self.parentRunId = parentRunId self.toolCallId = toolCallId self.streamState = streamState + self.veyraApproval = veyraApproval + } +} + +public struct ResponseMarkerStatus: Equatable, Sendable { + public let isActive: Bool + public let phaseText: String + public let elapsedText: String + public let tokenText: String + public let showsTokens: Bool + + public init(isActive: Bool = false, phaseText: String = "Resting", elapsedText: String = "0s", tokenText: String = "0 tokens", showsTokens: Bool = false) { + self.isActive = isActive + self.phaseText = phaseText + self.elapsedText = elapsedText + self.tokenText = tokenText + self.showsTokens = showsTokens + } +} + +public struct SlashCommand: Identifiable, Equatable, Sendable { + public let command: String + public let description: String + + public var id: String { command } + public var displayCommand: String { "/\(command)" } + + public init(command: String, description: String) { + self.command = command + self.description = description + } +} + +public struct SlashCommandPanelState: Equatable, Sendable { + public let command: SlashCommand + public let isRunning: Bool + public let output: String? + public let error: String? + public let options: [String] + + public init(command: SlashCommand, isRunning: Bool = false, output: String? = nil, error: String? = nil, options: [String] = []) { + self.command = command + self.isRunning = isRunning + self.output = output + self.error = error + self.options = options + } +} + +public struct HalosSessionSummary: Identifiable, Equatable, Sendable { + public let key: String + public let label: String + public let status: String + public let updatedAtText: String + public let updatedAt: Date? + public let preview: String + + public var id: String { key } + + public init(key: String, label: String, status: String, updatedAtText: String, updatedAt: Date? = nil, preview: String = "") { + self.key = key + self.label = label + self.status = status + self.updatedAtText = updatedAtText + self.updatedAt = updatedAt + self.preview = preview } } public enum GatewayStreamFormatter { + public enum ToolCategory: String, Sendable { + case tool + case plugin + } + + public struct ToolDisplay: Equatable, Sendable { + public let name: String + public let category: ToolCategory + + public init(name: String, category: ToolCategory) { + self.name = name + self.category = category + } + } + public static func extractText(from message: [String: Any]) -> String { if let text = message["text"] as? String, !text.isEmpty { return text @@ -101,25 +228,46 @@ public enum GatewayStreamFormatter { } public static func toolDisplayName(_ rawName: String?) -> String { + toolDisplay(rawName).name + } + + public static func toolDisplay(_ rawName: String?) -> ToolDisplay { let raw = (rawName ?? "tool").trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { return "Tool" } + guard !raw.isEmpty else { return ToolDisplay(name: "Tool", category: .tool) } let lower = raw.lowercased() if lower.contains("lazuli") { - return "Lazuli" + return ToolDisplay(name: "Computer Control", category: .plugin) } if lower.hasPrefix("edoras_safari__") || lower.contains("edoras") { - return "Edoras Safari" + return ToolDisplay(name: "Browser Control", category: .plugin) + } + if lower.hasPrefix("plugin__") || lower.hasPrefix("plugin_") || lower.hasPrefix("plugin:") { + return ToolDisplay(name: titleizedToolName(raw), category: .plugin) } if lower.contains("terminal") || lower == "bash" || lower == "shell" || lower == "exec" { - return "Terminal" + return ToolDisplay(name: "Terminal", category: .tool) } - if lower.contains("diff") { - return "Diff" + if lower.contains("read") + || lower.contains("write") + || lower.contains("edit") + || lower.contains("patch") + || lower.contains("file") + || lower.contains("glob") + || lower.contains("search") + || lower.contains("find") { + return ToolDisplay(name: "Files", category: .tool) } - if lower.contains("read") || lower.contains("write") || lower.contains("file") { - return "Files" + if lower.contains("diff") { + return ToolDisplay(name: "Diff", category: .tool) } - return raw + return ToolDisplay(name: titleizedToolName(raw), category: .tool) + } + + private static func titleizedToolName(_ raw: String) -> String { + raw + .replacingOccurrences(of: "plugin__", with: "") + .replacingOccurrences(of: "plugin_", with: "") + .replacingOccurrences(of: "plugin:", with: "") .replacingOccurrences(of: "__", with: " ") .replacingOccurrences(of: "_", with: " ") .split(separator: " ") @@ -130,19 +278,274 @@ public enum GatewayStreamFormatter { } public static func toolBody(phase: String?, name: String?, data: [String: Any]) -> String { - let displayName = toolDisplayName(name) + let display = toolDisplay(name) + if display.category == .plugin { + return pluginActionText(phase: phase, name: name) + } + return toolActionText(name: name, data: data) + } + + public static func toolActionText(name: String?, data: [String: Any]) -> String { + let raw = (name ?? "").lowercased() + if raw.contains("terminal") + || raw.contains("exec") + || raw.contains("command") + || raw == "bash" + || raw == "shell" { + return "Ran \(commandDetail(from: data) ?? "command")" + } + if raw.contains("write") + || raw.contains("edit") + || raw.contains("patch") + || raw.contains("apply") + || raw.contains("create") + || raw.contains("delete") + || raw.contains("move") + || raw.contains("rename") { + return "Edited \(fileDetail(from: data) ?? "file")" + } + if raw.contains("read") { + return "Read \(fileDetail(from: data) ?? "file")" + } + if raw.contains("search") || raw.contains("find") || raw.contains("grep") || raw.contains("rg") { + return "Searched \(fileDetail(from: data) ?? "files")" + } + if raw.contains("list") || raw.contains("glob") { + return "Listed \(fileDetail(from: data) ?? "files")" + } + if raw.contains("process") || raw == "ps" { + return "Checked \(processDetail(from: data) ?? "process")" + } + if raw.contains("file") { + return "Explored \(fileDetail(from: data) ?? "file")" + } + if let detail = fileDetail(from: data) ?? commandDetail(from: data) { + return "Used \(toolDisplayName(name)) \(detail)" + } + return "Used \(toolDisplayName(name))" + } + + private static func commandDetail(from data: [String: Any]) -> String? { + recursiveStringValue(in: data, keys: ["command", "cmd", "shell", "script", "argv", "input", "text"]) + .map(shortDetail) + } + + private static func fileDetail(from data: [String: Any]) -> String? { + recursiveStringValue(in: data, keys: [ + "path", + "paths", + "file", + "files", + "filePath", + "filepath", + "uri", + "url", + "query", + "pattern", + "glob", + ]) + .map(shortDetail) + } + + private static func processDetail(from data: [String: Any]) -> String? { + recursiveStringValue(in: data, keys: [ + "pid", + "pids", + "process", + "processName", + "process_name", + "command", + "cmd", + "query", + "pattern", + ]) + .map(shortDetail) + } + + private static func stringValue(in data: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = data[key] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return value + } + } + return nil + } + + private static func recursiveStringValue(in data: [String: Any], keys: [String]) -> String? { + var visited = Set() + return recursiveStringValue(in: data, keys: Set(keys.map { $0.lowercased() }), visited: &visited) + } + + private static func recursiveStringValue(in value: Any, keys: Set, visited: inout Set) -> String? { + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where keys.contains(key.lowercased()) { + if let direct = valueString(nested) { + return direct + } + } + + for containerKey in ["args", "arguments", "argumentsJson", "input", "inputJson", "params", "parameters", "payload", "request", "data"] { + guard let nested = dictionary[containerKey] ?? dictionary[containerKey.lowercased()] else { continue } + if let found = recursiveStringValue(in: nested, keys: keys, visited: &visited) { + return found + } + } + + for (key, nested) in dictionary where !["phase", "name", "toolCallId", "tool_call_id", "isError"].contains(key) { + if let found = recursiveStringValue(in: nested, keys: keys, visited: &visited) { + return found + } + } + return nil + } + + if let array = value as? [Any] { + if !array.isEmpty, array.allSatisfy({ $0 is String }) { + return array.compactMap { $0 as? String }.joined(separator: " ") + } + for item in array { + if let found = recursiveStringValue(in: item, keys: keys, visited: &visited) { + return found + } + } + return nil + } + + guard let string = value as? String else { return nil } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let parsed = parseJSONObject(trimmed) else { return trimmed } + let visitKey = String(trimmed.prefix(256)) + guard visited.insert(visitKey).inserted else { return nil } + return recursiveStringValue(in: parsed, keys: keys, visited: &visited) + } + + private static func valueString(_ value: Any?) -> String? { + guard let value else { return nil } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if let array = value as? [String], let first = array.first { + let suffix = array.count > 1 ? " +\(array.count - 1)" : "" + return first + suffix + } + if let array = value as? [Any], !array.isEmpty, array.allSatisfy({ $0 is String }) { + return array.compactMap { $0 as? String }.joined(separator: " ") + } + return nil + } + + private static func parseJSONObject(_ string: String) -> Any? { + guard let first = string.first, first == "{" || first == "[" else { return nil } + guard let data = string.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + + private static func shortDetail(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 52 else { return trimmed } + return "\(trimmed.prefix(49))..." + } + + private static func pluginActionText(phase: String?, name: String?) -> String { + let action = pluginActionName(name) switch phase { - case "start": - return "\(displayName) started" - case "update": - return "\(displayName) updated" + case "start", "update": + return progressivePluginAction(action) case "result": - if (data["isError"] as? Bool) == true { - return "\(displayName) failed" - } - return "\(displayName) completed" + return completedPluginAction(action) + default: + return action + } + } + + private static func pluginActionName(_ name: String?) -> String { + let raw = (name ?? "action").lowercased() + .replacingOccurrences(of: "edoras_safari__", with: "") + .replacingOccurrences(of: "lazuli_", with: "") + .replacingOccurrences(of: "plugin__", with: "") + .replacingOccurrences(of: "plugin_", with: "") + .replacingOccurrences(of: "plugin:", with: "") + .replacingOccurrences(of: "safari_", with: "") + + let normalized = raw.replacingOccurrences(of: "-", with: "_") + switch normalized { + case "click", "click_at", "double_click_at", "triple_click_at": + return "Click" + case "screenshot", "screen_capture": + return "Screenshot" + case "observe", "ax_snapshot", "windows": + return "Observe" + case "open_url", "open_tab": + return "Open" + case "navigate": + return "Navigate" + case "type", "set_value": + return "Type" + case "key", "press_action": + return "Key" + case "scroll": + return "Scroll" + case "drag": + return "Drag" + case "begin_task": + return "Begin task" + case "end_task", "end_session": + return "End session" + default: + return titleizedToolName(normalized) + } + } + + private static func progressivePluginAction(_ action: String) -> String { + switch action { + case "Click": + return "Clicking" + case "Screenshot": + return "Screenshotting" + case "Observe": + return "Observing" + case "Open": + return "Opening" + case "Navigate": + return "Navigating" + case "Type": + return "Typing" + case "Key": + return "Pressing key" + case "Scroll": + return "Scrolling" + case "Drag": + return "Dragging" + default: + return action + } + } + + private static func completedPluginAction(_ action: String) -> String { + switch action { + case "Click": + return "Clicked" + case "Screenshot": + return "Screenshot" + case "Observe": + return "Observed" + case "Open": + return "Opened" + case "Navigate": + return "Navigated" + case "Type": + return "Typed" + case "Key": + return "Pressed key" + case "Scroll": + return "Scrolled" + case "Drag": + return "Dragged" default: - return "\(displayName) ran" + return action } } } diff --git a/Sources/Halos/Stores/MissionControlStore.swift b/Sources/Halos/Stores/MissionControlStore.swift index 812d129..82e40ba 100644 --- a/Sources/Halos/Stores/MissionControlStore.swift +++ b/Sources/Halos/Stores/MissionControlStore.swift @@ -51,9 +51,12 @@ private struct GatewayDeviceIdentity: Codable { } private static func identityFileURL() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + let legacyBase = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") - return base.appending(path: "Halos/gateway-device.json") + let legacyURL = legacyBase.appending(path: "Halos/gateway-device.json") + try? HalosStorage.ensureLayout() + HalosStorage.migrateFile(from: legacyURL, to: HalosStorage.gatewayDeviceURL) + return HalosStorage.gatewayDeviceURL } private static func deviceId(for publicKey: Data) -> String { @@ -143,7 +146,7 @@ public enum HalosPage: String, CaseIterable, Identifiable, Sendable { case .tasks: return "checklist" case .settings: - return "halos.settings" + return "gearshape" } } } @@ -203,7 +206,21 @@ private struct OpenClawConfig: Decodable { let auth: Auth? } + struct Channels: Decodable { + struct Telegram: Decodable { + struct CustomCommand: Decodable { + let command: String? + let description: String? + } + + let customCommands: [CustomCommand]? + } + + let telegram: Telegram? + } + let gateway: Gateway? + let channels: Channels? } private struct CronStore: Decodable { @@ -269,6 +286,33 @@ private enum JSONValue: Decodable { } } +private struct RunToolRollup { + var toolNamesByCallID: [String: String] = [:] + var toolCategoriesByName: [String: GatewayStreamFormatter.ToolCategory] = [:] + var toolCounts: [String: Int] = [:] + var toolActionCounts: [String: Int] = [:] + var compactActionCounts: [String: Int] = [:] + var activeTools: Set = [] + var failedTools: Set = [] + var activePlugins: Set = [] + var latestEndedPlugin: String? + var pluginDetails: [String] = [] + var toolDetails: [String] = [] + var latestSummary: String? + + var hasVisibleToolActivity: Bool { + !toolActionCounts.isEmpty + || !compactActionCounts.isEmpty + || !pluginDetails.isEmpty + || !toolCounts.isEmpty + } +} + +private struct LocalSessionTitleOverride { + let preview: String + let updatedAt: Date +} + @MainActor public final class MissionControlStore: ObservableObject { public static let halosSessionKey = "agent:main:halos" @@ -278,6 +322,12 @@ public final class MissionControlStore: ObservableObject { @Published public private(set) var lazuliLifecycle: LifecycleState = .idle @Published public private(set) var messages: [ActivityMessage] = [] @Published public private(set) var automations: [AutomationSummary] = [] + @Published public private(set) var responseMarkerStatus = ResponseMarkerStatus() + @Published public private(set) var slashCommands: [SlashCommand] + @Published public private(set) var slashCommandPanelState: SlashCommandPanelState? + @Published public private(set) var halosSessions: [HalosSessionSummary] = [] + @Published public private(set) var currentSessionKey = MissionControlStore.halosSessionKey + @Published public var isCodeSessionBrowserPresented = true @Published public var selectedPage: HalosPage = .code @Published public var selectedAutomationID: String? @Published public var selectedViewMode: OpenClawViewMode = .plan @@ -286,50 +336,96 @@ public final class MissionControlStore: ObservableObject { private let openClawConfigURL: URL private let cronJobsURL: URL private let lazuliPortFileURL: URL + private let veyraPortFileURL: URL private var gatewayTask: URLSessionWebSocketTask? private var lazuliTask: URLSessionWebSocketTask? + private var veyraTask: URLSessionWebSocketTask? private var gatewayLoopTask: Task? private var lazuliLoopTask: Task? + private var veyraLoopTask: Task? private var pendingRequests: [String: (Bool, Any?) -> Void] = [:] private var shouldRun = false - private let sessionKey = MissionControlStore.halosSessionKey + private var sessionKey = MissionControlStore.halosSessionKey private var activeRunID: String? private var assistantMessageIDsByRunID: [String: String] = [:] private var statusMessageIDsByRunID: [String: String] = [:] - private var toolMessageIDsByToolCallID: [String: String] = [:] + private var workingMessageIDsByRunID: [String: String] = [:] + private var runStartedAtByRunID: [String: Date] = [:] + private var runToolRollupsByRunID: [String: RunToolRollup] = [:] + private var responseStatusTask: Task? + private var responseTokenCount = 0 + private var responseTokenCountIsEstimated = false + private var responseTokenVisibleRunIDs: Set = [] + private var thinkingWordsByRunID: [String: String] = [:] + private var thinkingWordCursor = 0 + private var activeSlashCommandRunID: String? + private var transientLocalCommandPanelTask: Task? + private var sessionGeneration = 0 + private var didSelectInitialHalosSession = false + private var localSessionTitleOverrides: [String: LocalSessionTitleOverride] = [:] public init( openClawConfigURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".openclaw/openclaw.json"), cronJobsURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".openclaw/cron/jobs.json"), - lazuliPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".lazuli/current-port") + lazuliPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".lazuli/current-port"), + veyraPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".veyra/current-port") ) { self.openClawConfigURL = openClawConfigURL self.cronJobsURL = cronJobsURL self.lazuliPortFileURL = lazuliPortFileURL + self.veyraPortFileURL = veyraPortFileURL + self.slashCommands = Self.loadHalosSlashCommands(from: openClawConfigURL) } public func start() { guard !shouldRun else { return } shouldRun = true loadAutomations() + slashCommands = Self.loadHalosSlashCommands(from: openClawConfigURL) gatewayLoopTask = Task { [weak self] in await self?.gatewayLoop() } lazuliLoopTask = Task { [weak self] in await self?.lazuliLoop() } + veyraLoopTask = Task { [weak self] in await self?.veyraLoop() } } public func stop() { shouldRun = false gatewayLoopTask?.cancel() lazuliLoopTask?.cancel() + veyraLoopTask?.cancel() gatewayTask?.cancel(with: .normalClosure, reason: nil) lazuliTask?.cancel(with: .normalClosure, reason: nil) + veyraTask?.cancel(with: .normalClosure, reason: nil) + responseStatusTask?.cancel() + transientLocalCommandPanelTask?.cancel() gatewayTask = nil lazuliTask = nil + veyraTask = nil + responseStatusTask = nil + transientLocalCommandPanelTask = nil + veyraLoopTask = nil + } + + public var isResponseMarkerActive: Bool { + responseMarkerStatus.isActive } public func sendDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } + if text == "/sessions" { + draft = "" + isCodeSessionBrowserPresented = true + refreshHalosSessions() + return + } + if let command = slashCommand(matching: text) { + runSlashCommand(command) + return + } draft = "" + if isCodeSessionBrowserPresented { + startFreshHalosSessionForComposer(initialText: text) + } append(ActivityMessage( id: "\(Date().timeIntervalSince1970)-user-\(UUID().uuidString)", @@ -338,10 +434,12 @@ public final class MissionControlStore: ObservableObject { body: text, createdAt: Date() )) + updateCurrentSessionTitle(with: text) let runID = UUID().uuidString - activeRunID = runID - upsertRunStatus(runID: runID, title: "Working", body: "Message sent.") + runStartedAtByRunID[runID] = Date() + activateResponseRun(runID: runID, resetTokens: true) + upsertRunStatus(runID: runID, title: thinkingWord(for: runID), body: "Processing your message.") sendGatewayRequest( method: "chat.send", @@ -356,7 +454,7 @@ public final class MissionControlStore: ObservableObject { if !ok { self.finishRun(runID, state: .failed) self.clearStatus(runID: runID) - self.activeRunID = nil + self.finishResponseRun(runID: runID) self.append(ActivityMessage( id: "\(Date().timeIntervalSince1970)-send-error-\(UUID().uuidString)", kind: .error, @@ -366,7 +464,9 @@ public final class MissionControlStore: ObservableObject { runId: runID, streamState: .failed )) + return } + self.patchCurrentSessionTitle(text) } } @@ -377,6 +477,315 @@ public final class MissionControlStore: ObservableObject { sendGatewayRequest(method: "chat.abort", params: ["sessionKey": sessionKey]) { _, _ in } } + public func resolveVeyraApproval(_ approvalID: String, decision: VeyraApprovalDecision) { + if decision == .ask { + draft = "For Veyra approval \(approvalID), tell Huma differently: " + } + sendVeyraControlMessage([ + "type": "approval.resolve", + "approvalId": approvalID, + "decision": decision.rawValue, + "note": decision == .ask ? "Tell Huma differently." : "Resolved from Halos.", + ]) + } + + public func runSlashCommand(_ command: SlashCommand) { + guard command.command != Self.halosNewSessionCommand.command else { + draft = "" + startNewHalosSession(command: command) + return + } + guard command.command != Self.halosClearCommand.command else { + draft = "" + clearLocalTranscript(command: command) + return + } + guard command.command != Self.halosFilesCommand.command else { + draft = "@" + slashCommandPanelState = nil + return + } + guard command.command != Self.halosAutomationsCommand.command else { + draft = "" + selectedPage = .automations + slashCommandPanelState = SlashCommandPanelState( + command: command, + isRunning: false, + output: "Opened automations." + ) + return + } + guard command.command != Self.halosPluginsCommand.command else { + draft = "" + slashCommandPanelState = SlashCommandPanelState( + command: command, + isRunning: false, + output: """ + Available plugin surfaces: + - Browser Control + - Computer Control + """ + ) + return + } + guard command.command != Self.halosStopCommand.command else { + draft = "" + requestStop() + slashCommandPanelState = SlashCommandPanelState( + command: command, + isRunning: false, + output: "Stop requested." + ) + return + } + draft = "" + sendSlashCommand(command, argument: nil, showsPanel: true) + } + + public func submitSlashCommandOption(_ option: String) { + let value = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, let state = slashCommandPanelState else { return } + draft = "" + slashCommandPanelState = nil + transientLocalCommandPanelTask?.cancel() + transientLocalCommandPanelTask = nil + sendSlashCommand(state.command, argument: value, showsPanel: false) + } + + private func sendSlashCommand(_ command: SlashCommand, argument: String?, showsPanel: Bool) { + let runID = UUID().uuidString + activeSlashCommandRunID = runID + if showsPanel { + slashCommandPanelState = SlashCommandPanelState(command: command, isRunning: true) + } + runStartedAtByRunID[runID] = Date() + activateResponseRun(runID: runID, resetTokens: true) + let message = [command.displayCommand, argument].compactMap { value in + value?.trimmingCharacters(in: .whitespacesAndNewlines) + } + .filter { !$0.isEmpty } + .joined(separator: " ") + + sendGatewayRequest( + method: "chat.send", + params: [ + "sessionKey": sessionKey, + "message": message, + "deliver": false, + "idempotencyKey": runID, + ] + ) { [weak self] ok, _ in + guard let self, !ok, self.activeSlashCommandRunID == runID else { return } + if showsPanel { + self.finishSlashCommandRun(runID: runID, error: "The gateway did not accept the command.") + } else { + self.activeSlashCommandRunID = nil + self.finishResponseRun(runID: runID) + } + } + } + + public func dismissSlashCommandPanel() { + slashCommandPanelState = nil + } + + public func beginSlashCommandForTesting(_ command: SlashCommand, runID: String) { + activeSlashCommandRunID = runID + slashCommandPanelState = SlashCommandPanelState(command: command, isRunning: true) + runStartedAtByRunID[runID] = Date() + activateResponseRun(runID: runID, resetTokens: true) + } + + public func refreshHalosSessions() { + sendGatewayRequest( + method: "sessions.list", + params: [ + "includeGlobal": true, + "includeUnknown": true, + "includeDerivedTitles": true, + "includeLastMessage": true, + "limit": 80, + "agentId": "main", + ] + ) { [weak self] ok, payload in + guard let self, ok else { return } + self.halosSessions = self.extractHalosSessions(from: payload) + self.selectInitialHalosSessionIfNeeded() + self.backfillMissingSessionPreviews() + } + } + + private func updateCurrentSessionTitle(with text: String) { + let preview = Self.sessionTitlePreview(from: text) + guard !preview.isEmpty else { return } + localSessionTitleOverrides[sessionKey] = LocalSessionTitleOverride(preview: preview, updatedAt: Date()) + let baseSessions = halosSessions.contains(where: { $0.key == sessionKey }) + ? halosSessions + : [currentSessionSummary] + halosSessions + halosSessions = sortHalosSessions(applyLocalSessionTitleOverrides(to: baseSessions)) + } + + private func patchCurrentSessionTitle(_ text: String) { + let preview = Self.sessionTitlePreview(from: text) + guard !preview.isEmpty else { return } + sendGatewayRequest( + method: "sessions.patch", + params: [ + "key": sessionKey, + "label": preview, + ] + ) { [weak self] ok, _ in + guard let self, ok else { return } + self.refreshHalosSessions() + } + } + + public func createHalosSession() { + startNewHalosSession(command: Self.halosNewSessionCommand) + } + + public func deleteHalosSession(_ key: String) { + guard isHalosSessionKey(key) else { return } + sendGatewayRequest( + method: "sessions.delete", + params: [ + "key": key, + "deleteTranscript": true, + ] + ) { [weak self] ok, _ in + guard let self else { return } + if ok { + self.deleteHalosSessionLocally(key) + self.refreshHalosSessions() + } else { + self.refreshHalosSessions() + self.append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-session-delete-error-\(UUID().uuidString)", + kind: .error, + title: "Session delete failed", + body: "The gateway did not delete that session.", + createdAt: Date() + )) + } + } + } + + private func deleteHalosSessionLocally(_ key: String) { + let remainingSessions = halosSessions.filter { $0.key != key } + halosSessions = remainingSessions + localSessionTitleOverrides.removeValue(forKey: key) + + if sessionKey == key { + messages = [] + isCodeSessionBrowserPresented = true + if let nextSession = remainingSessions.first { + sessionKey = nextSession.key + currentSessionKey = nextSession.key + } else { + sessionKey = Self.halosSessionKey + currentSessionKey = Self.halosSessionKey + } + } + } + + private func startNewHalosSession(command: SlashCommand) { + let suffix = UUID().uuidString.split(separator: "-").first.map(String.init) ?? "\(Int(Date().timeIntervalSince1970))" + let key = "\(Self.halosSessionKey):\(suffix.lowercased())" + let label = "Halos \(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))" + resetTranscriptState(for: key) + sendGatewayRequest( + method: "sessions.create", + params: [ + "key": key, + "agentId": "main", + "label": label, + ] + ) { [weak self] ok, _ in + guard let self else { return } + if ok { + self.refreshHalosSessions() + } + } + } + + private func startFreshHalosSessionForComposer(initialText: String) { + let suffix = UUID().uuidString.split(separator: "-").first.map(String.init) ?? "\(Int(Date().timeIntervalSince1970))" + let key = "\(Self.halosSessionKey):\(suffix.lowercased())" + let preview = Self.sessionTitlePreview(from: initialText) + resetTranscriptState(for: key) + localSessionTitleOverrides[key] = LocalSessionTitleOverride(preview: preview, updatedAt: Date()) + halosSessions = sortHalosSessions(applyLocalSessionTitleOverrides(to: [currentSessionSummary] + halosSessions)) + sendGatewayRequest( + method: "sessions.create", + params: [ + "key": key, + "agentId": "main", + "label": preview.isEmpty ? "Halos" : preview, + ] + ) { _, _ in } + } + + private func clearLocalTranscript(command: SlashCommand) { + messages = [] + activeRunID = nil + assistantMessageIDsByRunID.removeAll() + statusMessageIDsByRunID.removeAll() + workingMessageIDsByRunID.removeAll() + runStartedAtByRunID.removeAll() + runToolRollupsByRunID.removeAll() + responseTokenVisibleRunIDs.removeAll() + activeSlashCommandRunID = nil + finishResponseRun(runID: command.command) + showTransientLocalCommandPanel(SlashCommandPanelState( + command: command, + isRunning: false, + output: "Cleared the local transcript." + )) + } + + private func showTransientLocalCommandPanel(_ state: SlashCommandPanelState) { + slashCommandPanelState = state + guard state.options.isEmpty else { return } + transientLocalCommandPanelTask?.cancel() + transientLocalCommandPanelTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(state.error == nil ? 900 : 1800)) + await MainActor.run { + guard let self, self.slashCommandPanelState == state else { return } + self.slashCommandPanelState = nil + self.transientLocalCommandPanelTask = nil + } + } + } + + public func switchHalosSession(_ key: String) { + switchHalosSession(key, loadsHistory: true) + } + + private func switchHalosSession(_ key: String, loadsHistory: Bool) { + guard isHalosSessionKey(key) else { return } + resetTranscriptState(for: key) + ensureHalosSession(loadsHistory: loadsHistory) + refreshHalosSessions() + } + + private func resetTranscriptState(for key: String) { + sessionGeneration += 1 + sessionKey = key + currentSessionKey = key + isCodeSessionBrowserPresented = false + activeRunID = nil + assistantMessageIDsByRunID.removeAll() + statusMessageIDsByRunID.removeAll() + workingMessageIDsByRunID.removeAll() + runStartedAtByRunID.removeAll() + runToolRollupsByRunID.removeAll() + responseTokenVisibleRunIDs.removeAll() + activeSlashCommandRunID = nil + slashCommandPanelState = nil + messages = [] + finishResponseRun(runID: key) + } + func ingestGatewayEventForTesting(event: String, payload: [String: Any]) { if event == "chat" { handleChatEvent(payload) @@ -389,6 +798,23 @@ public final class MissionControlStore: ObservableObject { handleLazuli(jsonString: jsonString) } + func ingestVeyraEventForTesting(jsonString: String) { + handleVeyra(jsonString: jsonString) + } + + func loadHalosSessionsForTesting(payload: Any?) { + halosSessions = extractHalosSessions(from: payload) + selectInitialHalosSessionIfNeeded() + } + + func deleteHalosSessionLocallyForTesting(_ key: String) { + deleteHalosSessionLocally(key) + } + + func handleGatewayDisconnectForTesting() { + handleGatewayDisconnect() + } + private func loadAutomations() { guard let data = try? Data(contentsOf: cronJobsURL), @@ -442,14 +868,140 @@ public final class MissionControlStore: ObservableObject { private func scheduleText(_ schedule: CronJob.Schedule) -> String { if schedule.kind == "at", let at = schedule.at { - return at + return Self.formattedOneTimeSchedule(at) } if let expr = schedule.expr { - return [expr, schedule.tz].compactMap { $0 }.joined(separator: " - ") + return Self.formattedCronSchedule(expr) } return schedule.kind } + private static func formattedOneTimeSchedule(_ rawValue: String) -> String { + guard let date = isoDate(from: rawValue) else { return rawValue } + let formatter = DateFormatter() + formatter.dateFormat = Calendar.current.component(.year, from: date) == Calendar.current.component(.year, from: Date()) + ? "MMM d 'at' h:mm a" + : "MMM d, yyyy 'at' h:mm a" + formatter.amSymbol = "AM" + formatter.pmSymbol = "PM" + return formatter.string(from: date) + } + + private static func isoDate(from rawValue: String) -> Date? { + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = parser.date(from: rawValue) { + return date + } + parser.formatOptions = [.withInternetDateTime] + return parser.date(from: rawValue) + } + + private static func formattedCronSchedule(_ expression: String) -> String { + let fields = expression + .split(whereSeparator: \.isWhitespace) + .map(String.init) + let cronFields: [String] + if fields.count == 6 { + cronFields = Array(fields.dropFirst()) + } else { + cronFields = fields + } + guard cronFields.count == 5 else { + return "Cron: \(expression)" + } + + let minute = cronFields[0] + let hour = cronFields[1] + let dayOfMonth = cronFields[2] + let month = cronFields[3] + let dayOfWeek = cronFields[4] + let time = formattedCronTime(hour: hour, minute: minute) + + if dayOfMonth == "*", month == "*", dayOfWeek == "*" { + return "Daily at \(time)" + } + if dayOfMonth == "*", month == "*", let dayText = formattedCronWeekday(dayOfWeek) { + return "\(dayText) at \(time)" + } + if month == "*", dayOfWeek == "*", let dayText = formattedDayOfMonth(dayOfMonth) { + return "Monthly on the \(dayText) at \(time)" + } + if let monthText = formattedCronMonth(month), let dayText = formattedDayOfMonth(dayOfMonth), dayOfWeek == "*" { + return "\(monthText) \(dayText) at \(time)" + } + return "Cron: \(expression)" + } + + private static func formattedCronTime(hour: String, minute: String) -> String { + guard + let hourValue = Int(hour), + let minuteValue = Int(minute), + (0...23).contains(hourValue), + (0...59).contains(minuteValue) + else { + return "\(hour):\(minute)" + } + if hourValue == 0 && minuteValue == 0 { + return "midnight" + } + if hourValue == 12 && minuteValue == 0 { + return "noon" + } + let displayHour = hourValue % 12 == 0 ? 12 : hourValue % 12 + let suffix = hourValue < 12 ? "AM" : "PM" + return String(format: "%d:%02d %@", displayHour, minuteValue, suffix) + } + + private static func formattedCronWeekday(_ value: String) -> String? { + if value == "*" { return nil } + if value == "1-5" { + return "Weekdays" + } + if value == "0,6" || value == "6,0" { + return "Weekends" + } + let names = [ + "0": "Sundays", + "1": "Mondays", + "2": "Tuesdays", + "3": "Wednesdays", + "4": "Thursdays", + "5": "Fridays", + "6": "Saturdays", + "7": "Sundays", + ] + if let name = names[value] { + return name + } + let parts = value.split(separator: ",").map(String.init) + let resolved = parts.compactMap { names[$0]?.replacingOccurrences(of: "s", with: "", options: [.anchored, .backwards]) } + guard resolved.count == parts.count, !resolved.isEmpty else { return nil } + return resolved.joined(separator: ", ") + } + + private static func formattedDayOfMonth(_ value: String) -> String? { + guard let day = Int(value), (1...31).contains(day) else { return nil } + let suffix: String + if (11...13).contains(day % 100) { + suffix = "th" + } else { + switch day % 10 { + case 1: suffix = "st" + case 2: suffix = "nd" + case 3: suffix = "rd" + default: suffix = "th" + } + } + return "\(day)\(suffix)" + } + + private static func formattedCronMonth(_ value: String) -> String? { + guard let month = Int(value), (1...12).contains(month) else { return nil } + let formatter = DateFormatter() + return formatter.shortMonthSymbols[month - 1] + } + private func gatewayLoop() async { while shouldRun && !Task.isCancelled { gatewayConnection = .searching @@ -474,6 +1026,7 @@ public final class MissionControlStore: ObservableObject { gatewayTask = nil } if shouldRun { + handleGatewayDisconnect() gatewayConnection = .offline try? await Task.sleep(for: .seconds(2)) } @@ -533,7 +1086,8 @@ public final class MissionControlStore: ObservableObject { let handler = pendingRequests.removeValue(forKey: id) if id.hasPrefix("connect-"), ok { gatewayConnection = .connected - ensureHalosSession() + ensureHalosSession(loadsHistory: true) + refreshHalosSessions() } handler?(ok, payload) } @@ -614,25 +1168,29 @@ public final class MissionControlStore: ObservableObject { ] } - private func ensureHalosSession() { + private func ensureHalosSession(loadsHistory: Bool) { + let requestSessionKey = sessionKey + let requestGeneration = sessionGeneration sendGatewayRequest( method: "sessions.create", params: [ - "key": sessionKey, + "key": requestSessionKey, "agentId": "main", "label": "Halos", ] ) { [weak self] _, _ in - self?.loadChatHistory() + guard loadsHistory else { return } + self?.loadChatHistory(sessionKey: requestSessionKey, generation: requestGeneration) } } - private func loadChatHistory() { + private func loadChatHistory(sessionKey requestSessionKey: String, generation requestGeneration: Int) { sendGatewayRequest( method: "chat.history", - params: ["sessionKey": sessionKey, "limit": 20, "maxChars": 4000] + params: ["sessionKey": requestSessionKey, "limit": 20, "maxChars": 4000] ) { [weak self] ok, payload in guard ok, let self else { return } + guard self.sessionKey == requestSessionKey, self.sessionGeneration == requestGeneration else { return } let items = self.extractHistoryMessages(from: payload) if !items.isEmpty { self.messages = items @@ -648,103 +1206,539 @@ public final class MissionControlStore: ObservableObject { return rawMessages.compactMap { raw in let role = (raw["role"] as? String) ?? "" let text = extractText(from: raw) - guard !text.isEmpty else { return nil } + guard shouldDisplayChatMessage(role: role, text: text) else { return nil } + let isAutomated = isAutomatedRuntimeMessage(text) return ActivityMessage( id: (raw["id"] as? String) ?? "\(Date().timeIntervalSince1970)-history-\(UUID().uuidString)", - kind: role == "user" ? .user : .assistant, - title: role == "user" ? "You" : "Huma", - body: text, + kind: isAutomated ? .system : (role == "user" ? .user : .assistant), + title: isAutomated ? automatedRuntimeTitle(for: text) : (role == "user" ? "You" : "Huma"), + body: isAutomated ? automatedRuntimeBody(for: text) : text, createdAt: Date(timeIntervalSince1970: ((raw["timestamp"] as? Double) ?? Date().timeIntervalSince1970 * 1000) / 1000) ) } } - private func handleChatEvent(_ payload: [String: Any]) { - guard eventBelongsToCurrentSession(payload) else { return } - let state = payload["state"] as? String - let runID = (payload["runId"] as? String) ?? activeRunID ?? UUID().uuidString - activeRunID = runID - - if state == "delta", let message = payload["message"] as? [String: Any] { - let text = extractText(from: message) - guard !text.isEmpty else { return } - upsertAssistantMessage(runID: runID, body: text, state: .streaming) - return - } - - if state == "final" { - if let message = payload["message"] as? [String: Any] { - let text = extractText(from: message) - if !text.isEmpty { - upsertAssistantMessage(runID: runID, body: text, state: .final) + private func backfillMissingSessionPreviews() { + let missingSessions = halosSessions + .filter { $0.preview.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .prefix(20) + + for session in missingSessions { + sendGatewayRequest( + method: "chat.history", + params: ["sessionKey": session.key, "limit": 40, "maxChars": 8000] + ) { [weak self] ok, payload in + guard + let self, + ok, + let userMessage = self.latestUserMessagePreview(from: payload) + else { + return } - } else { - finishRun(runID, state: .final) + self.localSessionTitleOverrides[session.key] = LocalSessionTitleOverride( + preview: userMessage.preview, + updatedAt: userMessage.createdAt + ) + self.halosSessions = self.sortHalosSessions(self.applyLocalSessionTitleOverrides(to: self.halosSessions)) + self.selectInitialHalosSessionIfNeeded() } - clearStatus(runID: runID) - activeRunID = nil - return } + } - if state == "error" { - finishRun(runID, state: .failed) - clearStatus(runID: runID) - append(ActivityMessage( - id: "\(Date().timeIntervalSince1970)-chat-error-\(UUID().uuidString)", - kind: .error, - title: "Huma", - body: (payload["errorMessage"] as? String) ?? "OpenClaw reported an error.", - createdAt: Date(), - runId: runID, - streamState: .failed - )) - activeRunID = nil - return + private func selectInitialHalosSessionIfNeeded() { + guard !didSelectInitialHalosSession else { return } + guard isCodeSessionBrowserPresented, messages.isEmpty else { return } + guard let firstSession = halosSessions.first else { return } + sessionKey = firstSession.key + currentSessionKey = firstSession.key + didSelectInitialHalosSession = true + } + + private func latestUserMessagePreview(from payload: Any?) -> (preview: String, createdAt: Date)? { + let rawMessages = + (payload as? [String: Any])?["messages"] as? [[String: Any]] + ?? (payload as? [[String: Any]]) + ?? [] + + for raw in rawMessages.reversed() { + let role = ((raw["role"] as? String) ?? "").lowercased() + guard role == "user" else { continue } + let text = extractText(from: raw) + guard shouldDisplayChatMessage(role: role, text: text) else { continue } + guard !isAutomatedRuntimeMessage(text) else { continue } + let preview = Self.sessionTitlePreview(from: text) + guard !preview.isEmpty else { continue } + let createdAt = Self.date(from: raw["timestamp"] ?? raw["createdAt"] ?? raw["createdAtMs"] ?? raw["time"]) + ?? Date() + return (preview, createdAt) } - if state == "aborted" { - finishRun(runID, state: .aborted) - clearStatus(runID: runID) - append(ActivityMessage( - id: "\(Date().timeIntervalSince1970)-chat-aborted-\(UUID().uuidString)", - kind: .system, - title: "Stopped", - body: "Run was aborted.", - createdAt: Date(), - runId: runID, - streamState: .aborted - )) - activeRunID = nil + return nil + } + + private func extractHalosSessions(from payload: Any?) -> [HalosSessionSummary] { + let rawSessions = + (payload as? [String: Any])?["sessions"] as? [[String: Any]] + ?? (payload as? [[String: Any]]) + ?? [] + + let sessions = rawSessions.compactMap { raw -> HalosSessionSummary? in + guard let key = (raw["key"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + isHalosSessionKey(key) + else { + return nil + } + let rawLabel = ((raw["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + let label = rawLabel + ?? Self.defaultSessionLabel(for: key) + let status = ((raw["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "idle" + let updatedAt = Self.date(from: raw["updatedAt"] ?? raw["lastActivityAt"] ?? raw["lastMessageAt"] ?? raw["createdAt"]) + let updatedAtText = Self.relativeTimeText( + from: raw["updatedAt"] ?? raw["lastActivityAt"] ?? raw["lastMessageAt"] ?? raw["createdAt"] + ) + let preview = Self.sessionPreview(from: raw, fallback: Self.fallbackSessionPreview(for: key, label: rawLabel)) + return HalosSessionSummary(key: key, label: label, status: status, updatedAtText: updatedAtText, updatedAt: updatedAt, preview: preview) } + + let hasCurrent = sessions.contains { $0.key == sessionKey } + let shouldInsertCurrent = !hasCurrent && (sessions.isEmpty || sessionKey != Self.halosSessionKey) + let allSessions = shouldInsertCurrent + ? [HalosSessionSummary(key: sessionKey, label: Self.defaultSessionLabel(for: sessionKey), status: "current", updatedAtText: "now", updatedAt: Date(), preview: currentSessionPreview())] + sessions + : sessions + + return sortHalosSessions(applyLocalSessionTitleOverrides(to: allSessions)) } - private func handleAgentEvent(_ payload: [String: Any]) { - guard eventBelongsToCurrentSession(payload) else { return } - let stream = payload["stream"] as? String - let runID = (payload["runId"] as? String) ?? activeRunID ?? UUID().uuidString - activeRunID = runID - let data = payload["data"] as? [String: Any] ?? [:] + private func applyLocalSessionTitleOverrides(to sessions: [HalosSessionSummary]) -> [HalosSessionSummary] { + sessions.map { session in + guard let override = localSessionTitleOverrides[session.key] else { return session } + return HalosSessionSummary( + key: session.key, + label: session.label, + status: session.status, + updatedAtText: Self.relativeTimeText(from: override.updatedAt), + updatedAt: override.updatedAt, + preview: override.preview + ) + } + } - if let status = GatewayStreamFormatter.lifecycleCopy(stream: stream, phase: data["phase"] as? String) { - upsertRunStatus(runID: runID, title: status.title, body: status.body) - if data["phase"] as? String == "end" { - clearStatus(runID: runID) + private func sortHalosSessions(_ sessions: [HalosSessionSummary]) -> [HalosSessionSummary] { + sessions.sorted { lhs, rhs in + switch (lhs.updatedAt, rhs.updatedAt) { + case let (lhsDate?, rhsDate?): + if lhsDate != rhsDate { return lhsDate > rhsDate } + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + break } - return + return lhs.key.localizedCaseInsensitiveCompare(rhs.key) == .orderedAscending + } + } + + private func isHalosSessionKey(_ key: String) -> Bool { + key == Self.halosSessionKey || key.hasPrefix("\(Self.halosSessionKey):") + } + + private static func defaultSessionLabel(for key: String) -> String { + guard key != halosSessionKey else { return "Halos" } + let suffix = key.replacingOccurrences(of: "\(halosSessionKey):", with: "") + return suffix.isEmpty ? "Halos" : "Halos \(suffix.prefix(8))" + } + + public var currentSessionSummary: HalosSessionSummary { + let session = halosSessions.first(where: { $0.key == sessionKey }) + let preview = currentSessionPreview() + if let session { + if let override = localSessionTitleOverrides[session.key] { + return HalosSessionSummary( + key: session.key, + label: session.label, + status: session.status, + updatedAtText: Self.relativeTimeText(from: override.updatedAt), + updatedAt: override.updatedAt, + preview: override.preview + ) + } + return HalosSessionSummary( + key: session.key, + label: session.label, + status: session.status, + updatedAtText: session.updatedAtText, + updatedAt: session.updatedAt, + preview: preview.isEmpty ? session.preview : preview + ) + } + return HalosSessionSummary( + key: sessionKey, + label: Self.defaultSessionLabel(for: sessionKey), + status: "current", + updatedAtText: localSessionTitleOverrides[sessionKey].map { Self.relativeTimeText(from: $0.updatedAt) } ?? "now", + updatedAt: localSessionTitleOverrides[sessionKey]?.updatedAt, + preview: localSessionTitleOverrides[sessionKey]?.preview ?? preview + ) + } + + private func currentSessionPreview() -> String { + messages + .reversed() + .first(where: { $0.kind == .user })? + .body + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + } + + private static func sessionPreview(from raw: [String: Any], fallback: String) -> String { + let value = + raw["lastUserMessagePreview"] + ?? raw["lastUserMessage"] + let text: String? + if let string = value as? String { + text = string + } else if let object = value as? [String: Any] { + text = (object["text"] as? String) ?? (object["preview"] as? String) + } else { + text = nil + } + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let preview = sessionTitlePreview(from: trimmed) + return preview.isEmpty ? fallback : preview + } + + private static func fallbackSessionPreview(for key: String, label: String?) -> String { + "" + } + + private static func sessionTitlePreview(from text: String) -> String { + let preview = text + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) ?? "" + guard preview.count > 96 else { return preview } + let endIndex = preview.index(preview.startIndex, offsetBy: 96) + return preview[.. String { + let date = date(from: value) + guard let date else { return "unknown" } + let seconds = max(0, Int(Date().timeIntervalSince(date))) + if seconds < 60 { return "now" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } + + private static func date(from value: Any?) -> Date? { + if let double = value as? Double { + return Date(timeIntervalSince1970: double > 10_000_000_000 ? double / 1000 : double) + } + if let int = value as? Int { + let double = Double(int) + return Date(timeIntervalSince1970: double > 10_000_000_000 ? double / 1000 : double) + } + if let string = value as? String { + return ISO8601DateFormatter().date(from: string) + } + if let dateValue = value as? Date { + return dateValue + } + return nil + } + + private func slashCommand(matching text: String) -> SlashCommand? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { return nil } + let commandName = String(trimmed.dropFirst()) + guard !commandName.isEmpty, !commandName.contains(where: \.isWhitespace) else { return nil } + return slashCommands.first { $0.command.caseInsensitiveCompare(commandName) == .orderedSame } + } + + private func shouldDisplayChatMessage(role: String, text: String) -> Bool { + let normalizedRole = role.lowercased() + guard normalizedRole == "user" || normalizedRole == "assistant" else { return false } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + if normalizedRole == "user", isSlashCommandInput(trimmed) { return false } + if isSlashCommandResultMessage(trimmed) { return false } + return !isInternalRuntimeDump(trimmed) + } + + private func isSlashCommandInput(_ text: String) -> Bool { + guard text.hasPrefix("/") else { return false } + let commandName = text.dropFirst().split(whereSeparator: \.isWhitespace).first.map(String.init) ?? "" + return !commandName.isEmpty + } + + private func isSlashCommandResultMessage(_ text: String) -> Bool { + let lines = text + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !lines.isEmpty else { return false } + + let lowercased = lines.map { $0.lowercased() } + if lowercased.contains(where: { $0.hasPrefix("options:") }) { + return true + } + if lowercased.contains(where: { $0.hasPrefix("current ") && ($0.contains(" level:") || $0.contains(" mode:")) }) { + return true + } + if lowercased.contains(where: { $0.contains("logging enabled") || $0.contains("logging disabled") }) { + return true + } + if lowercased.contains(where: { $0.hasPrefix("reasoning ") || $0.hasPrefix("verbose ") || $0.hasPrefix("thinking depth ") }) { + return true + } + return false + } + + private func isAutomatedRuntimeMessage(_ text: String) -> Bool { + let lowercased = text.lowercased() + return lowercased.hasPrefix("system (untrusted):") + || lowercased.hasPrefix("system:") + || lowercased.contains(" exec completed ") + || lowercased.contains(" gateway restart ") + || lowercased.contains("an async command you ran earlier has completed.") + || lowercased.contains("the result is shown in the system messages above.") + || (lowercased.contains("current time:") && lowercased.contains("read heartbeat.md")) + } + + private func automatedRuntimeTitle(for text: String) -> String { + let firstLine = text + .split(whereSeparator: \.isNewline) + .map(String.init) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "System" + let lowercased = firstLine.lowercased() + if lowercased.hasPrefix("system (untrusted):") || lowercased.hasPrefix("system:") { + return "System notice" + } + return firstLine.isEmpty ? "System notice" : firstLine + } + + private func automatedRuntimeBody(for text: String) -> String { + let lines = text + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let filtered = lines.filter { line in + let lowercased = line.lowercased() + return !lowercased.hasPrefix("system (untrusted):") + && !lowercased.hasPrefix("system:") + && !lowercased.hasPrefix("current time:") + && !line.allSatisfy { $0 == "|" || $0 == "-" || $0 == " " } + } + let summaryLine = filtered.first { line in + line.lowercased().contains("async command") + || line.lowercased().contains("completed") + } + return summaryLine ?? filtered.first ?? "Background command completed." + } + + private func isInternalRuntimeDump(_ text: String) -> Bool { + let lowercased = text.lowercased() + let markers = [ + "[startup context loaded by runtime]", + "[untrusted daily memory:", + "begin_quoted_notes", + "end_quoted_notes", + "a new session was started via /new or /reset", + "execute your session startup sequence now", + "bootstrap files like soul.md", + ] + return markers.contains { lowercased.contains($0) } + } + + private func updateSlashCommandRun(runID: String, output: String?, isRunning: Bool, error: String? = nil) { + guard activeSlashCommandRunID == runID, let current = slashCommandPanelState else { return } + slashCommandPanelState = SlashCommandPanelState( + command: current.command, + isRunning: isRunning, + output: output ?? current.output, + error: error, + options: Self.extractSlashCommandOptions(from: output ?? current.output) + ) + } + + private func finishSlashCommandRun(runID: String, output: String? = nil, error: String? = nil) { + updateSlashCommandRun(runID: runID, output: output, isRunning: false, error: error) + if activeSlashCommandRunID == runID { + activeSlashCommandRunID = nil + } + finishResponseRun(runID: runID) + clearStatus(runID: runID) + } + + private static func extractSlashCommandOptions(from output: String?) -> [String] { + guard let output else { return [] } + let lines = output.split(whereSeparator: \.isNewline).map(String.init) + guard let optionsLine = lines.first(where: { $0.lowercased().hasPrefix("options:") }) else { return [] } + let rawOptions = optionsLine.dropFirst("Options:".count) + return rawOptions + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "."))) } + .filter { !$0.isEmpty } + } + + private func handleChatEvent(_ payload: [String: Any]) { + guard eventBelongsToCurrentSession(payload) else { return } + let state = payload["state"] as? String + let runID = (payload["runId"] as? String) ?? activeRunID ?? UUID().uuidString + activateResponseRun(runID: runID) + updateResponseTokens(from: payload) + + if activeSlashCommandRunID == runID { + if let message = payload["message"] as? [String: Any] { + let text = extractText(from: message) + if !text.isEmpty { + updateEstimatedResponseTokens(from: text) + updateSlashCommandRun(runID: runID, output: text, isRunning: state != "final") + } + } + if state == "final" { + finishSlashCommandRun(runID: runID) + } else if state == "error" { + finishSlashCommandRun( + runID: runID, + error: (payload["errorMessage"] as? String) ?? "OpenClaw reported an error." + ) + } else if state == "aborted" { + finishSlashCommandRun(runID: runID, error: "Command was aborted.") + } + return + } + + if state == "delta", let message = payload["message"] as? [String: Any] { + let role = (message["role"] as? String) ?? "assistant" + let text = extractText(from: message) + guard shouldDisplayChatMessage(role: role, text: text) else { + clearStatus(runID: runID) + return + } + if appendAutomatedRuntimeMessageIfNeeded(text, runID: runID, state: .streaming) { + clearStatus(runID: runID) + return + } + updateEstimatedResponseTokens(from: text) + clearStatus(runID: runID) + markResponseTokensVisible(runID: runID) + closeToolSummarySegmentIfNeeded(runID: runID) + upsertAssistantMessage(runID: runID, body: text, state: .streaming) + return + } + + if state == "final" { + if let message = payload["message"] as? [String: Any] { + let role = (message["role"] as? String) ?? "assistant" + let text = extractText(from: message) + guard shouldDisplayChatMessage(role: role, text: text) else { + clearStatus(runID: runID) + finishResponseRun(runID: runID) + return + } + if appendAutomatedRuntimeMessageIfNeeded(text, runID: runID, state: .final) { + clearStatus(runID: runID) + finishResponseRun(runID: runID) + return + } + updateEstimatedResponseTokens(from: text) + markResponseTokensVisible(runID: runID) + closeToolSummarySegmentIfNeeded(runID: runID) + upsertAssistantMessage(runID: runID, body: text, state: .final) + } else { + finishRun(runID, state: .final) + } + clearStatus(runID: runID) + finalizeWorkingSummary(runID: runID) + finishResponseRun(runID: runID) + return + } + + if state == "error" { + finishRun(runID, state: .failed) + clearStatus(runID: runID) + upsertWorkingSummary(runID: runID, latestSummary: "Stopped after an error.") + append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-chat-error-\(UUID().uuidString)", + kind: .error, + title: "Huma", + body: (payload["errorMessage"] as? String) ?? "OpenClaw reported an error.", + createdAt: Date(), + runId: runID, + streamState: .failed + )) + finishResponseRun(runID: runID) + return + } + + if state == "aborted" { + finishRun(runID, state: .aborted) + clearStatus(runID: runID) + upsertWorkingSummary(runID: runID, latestSummary: "Stopped by request.") + append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-chat-aborted-\(UUID().uuidString)", + kind: .system, + title: "Stopped", + body: "Run was aborted.", + createdAt: Date(), + runId: runID, + streamState: .aborted + )) + finishResponseRun(runID: runID) + } + } + + private func handleAgentEvent(_ payload: [String: Any]) { + guard eventBelongsToCurrentSession(payload) else { return } + let stream = payload["stream"] as? String + let runID = (payload["runId"] as? String) ?? activeRunID ?? UUID().uuidString + activateResponseRun(runID: runID) + updateResponseTokens(from: payload) + let data = payload["data"] as? [String: Any] ?? [:] + + if activeSlashCommandRunID == runID { + if let status = GatewayStreamFormatter.lifecycleCopy(stream: stream, phase: data["phase"] as? String) { + updateSlashCommandRun(runID: runID, output: status.body, isRunning: true) + } + return + } + + if let status = GatewayStreamFormatter.lifecycleCopy(stream: stream, phase: data["phase"] as? String) { + let phase = data["phase"] as? String + if phase == "start" { + upsertRunStatus(runID: runID, title: thinkingWord(for: runID), body: status.body) + } else { + clearStatus(runID: runID) + upsertWorkingSummary(runID: runID, latestSummary: status.body) + if phase == "end" || phase == "error" || phase == "abort" || phase == "aborted" { + finishResponseRun(runID: runID) + } + } + return } guard stream == "tool" else { return } + clearStatus(runID: runID) let toolCallID = (data["toolCallId"] as? String) ?? "\(runID)-\((data["name"] as? String) ?? "tool")" let name = data["name"] as? String let phase = data["phase"] as? String - let title = GatewayStreamFormatter.toolDisplayName(name) + let display = GatewayStreamFormatter.toolDisplay(name) let body = GatewayStreamFormatter.toolBody(phase: phase, name: name, data: data) - upsertToolMessage( + recordToolEvent( runID: runID, toolCallID: toolCallID, - title: title, + title: display.name, + category: display.category, body: body, state: (data["isError"] as? Bool) == true ? .failed : (phase == "result" ? .final : .streaming) ) @@ -801,6 +1795,74 @@ public final class MissionControlStore: ObservableObject { return try? JSONDecoder().decode(OpenClawConfig.self, from: data) } + private static func loadHalosSlashCommands(from url: URL) -> [SlashCommand] { + guard + let data = try? Data(contentsOf: url), + let config = try? JSONDecoder().decode(OpenClawConfig.self, from: data) + else { + return halosLocalCommands + } + + var telegramCommands: [SlashCommand] = (config.channels?.telegram?.customCommands ?? []) + .compactMap { raw -> SlashCommand? in + guard let command = raw.command?.trimmingCharacters(in: .whitespacesAndNewlines), !command.isEmpty else { + return nil + } + let normalizedCommand = command.hasPrefix("/") ? String(command.dropFirst()) : command + guard !normalizedCommand.isEmpty else { return nil } + let description = raw.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return SlashCommand(command: normalizedCommand, description: description) + } + .filter { $0.command != "sessions" } + + if !telegramCommands.contains(where: { $0.command == halosNewSessionCommand.command }) { + telegramCommands.append(halosNewSessionCommand) + } + for command in halosLocalCommands where !telegramCommands.contains(where: { $0.command == command.command }) { + telegramCommands.append(command) + } + return telegramCommands + } + + private static let halosNewSessionCommand = SlashCommand( + command: "new", + description: "Start a fresh Halos session" + ) + + private static let halosFilesCommand = SlashCommand( + command: "files", + description: "Search files to attach as context" + ) + + private static let halosClearCommand = SlashCommand( + command: "clear", + description: "Clear the local transcript" + ) + + private static let halosPluginsCommand = SlashCommand( + command: "plugins", + description: "Show available control plugins" + ) + + private static let halosAutomationsCommand = SlashCommand( + command: "automations", + description: "Open automations" + ) + + private static let halosStopCommand = SlashCommand( + command: "stop", + description: "Stop the current run" + ) + + private static let halosLocalCommands = [ + halosNewSessionCommand, + halosFilesCommand, + halosClearCommand, + halosPluginsCommand, + halosAutomationsCommand, + halosStopCommand, + ] + private func lazuliLoop() async { while shouldRun && !Task.isCancelled { lazuliConnection = .searching @@ -832,6 +1894,51 @@ public final class MissionControlStore: ObservableObject { } } + private func veyraLoop() async { + while shouldRun && !Task.isCancelled { + guard let port = readVeyraPort() else { + try? await Task.sleep(for: .milliseconds(1500)) + continue + } + + guard let url = URL(string: "ws://127.0.0.1:\(port)/events") else { + try? await Task.sleep(for: .milliseconds(1500)) + continue + } + + let task = URLSession.shared.webSocketTask(with: url) + veyraTask = task + task.resume() + await receiveVeyraMessages(from: task) + if veyraTask === task { + veyraTask = nil + } + if shouldRun { + try? await Task.sleep(for: .milliseconds(1500)) + } + } + } + + private func receiveVeyraMessages(from task: URLSessionWebSocketTask) async { + while shouldRun && !Task.isCancelled { + do { + let message = try await task.receive() + switch message { + case .string(let string): + handleVeyra(jsonString: string) + case .data(let data): + if let string = String(data: data, encoding: .utf8) { + handleVeyra(jsonString: string) + } + @unknown default: + break + } + } catch { + return + } + } + } + private func receiveLazuliMessages(from task: URLSessionWebSocketTask) async { while shouldRun && !Task.isCancelled { do { @@ -862,17 +1969,144 @@ public final class MissionControlStore: ObservableObject { return port } + private func readVeyraPort() -> Int? { + guard + let raw = try? String(contentsOf: veyraPortFileURL, encoding: .utf8), + let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + return nil + } + return port + } + + private func handleVeyra(jsonString: String) { + guard + let data = jsonString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = object["type"] as? String + else { return } + + switch type { + case "approval.requested": + guard let message = veyraApprovalMessage(from: object) else { return } + upsertVeyraApprovalMessage(message) + case "approval.resolved": + guard let message = veyraApprovalMessage(from: object) else { return } + upsertVeyraApprovalMessage(message) + case "log": + guard let level = object["level"] as? String, level == "error" else { return } + append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-veyra-log-\(UUID().uuidString)", + kind: .error, + title: "Veyra", + body: (object["message"] as? String) ?? "Veyra reported an error.", + createdAt: Date() + )) + default: + break + } + } + + private func veyraApprovalMessage(from object: [String: Any]) -> ActivityMessage? { + guard + let approval = object["approval"] as? [String: Any], + let draft = object["draft"] as? [String: Any], + let approvalID = approval["id"] as? String, + let draftID = approval["draftId"] as? String, + let providerID = approval["providerId"] as? String, + let status = approval["status"] as? String, + let riskLevel = approval["riskLevel"] as? String, + let kind = draft["kind"] as? String, + let body = draft["body"] as? String + else { + return nil + } + + let card = VeyraApprovalCard( + approvalId: approvalID, + draftId: draftID, + providerId: providerID, + accountId: approval["accountId"] as? String, + status: status, + riskLevel: riskLevel, + kind: kind, + title: draft["title"] as? String, + body: body, + createdAtText: shortTimeText(from: approval["createdAt"] as? String) + ) + return ActivityMessage( + id: "veyra-\(approvalID)", + kind: .system, + title: status == "pending" ? "Veyra approval needed" : "Veyra approval resolved", + body: body, + createdAt: Date(), + veyraApproval: card + ) + } + + private func upsertVeyraApprovalMessage(_ message: ActivityMessage) { + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = message + } else { + append(message) + } + } + + private func shortTimeText(from raw: String?) -> String { + guard let raw else { return "now" } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = parser.date(from: raw) ?? ISO8601DateFormatter().date(from: raw) + guard let date else { return "now" } + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: date) + } + + private func sendVeyraControlMessage(_ payload: [String: Any]) { + guard + let veyraTask, + JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: data, encoding: .utf8) + else { + append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-veyra-send-error-\(UUID().uuidString)", + kind: .error, + title: "Veyra", + body: "Veyra is not connected, so the approval could not be sent.", + createdAt: Date() + )) + return + } + + veyraTask.send(.string(text)) { error in + guard let error else { return } + Task { @MainActor [weak self] in + self?.append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-veyra-send-error-\(UUID().uuidString)", + kind: .error, + title: "Veyra", + body: error.localizedDescription, + createdAt: Date() + )) + } + } + } + private func handleLazuli(jsonString: String) { let result = LazuliMessageFormatter.format(jsonString: jsonString) if let lifecycle = result.lifecycle { lazuliLifecycle = lifecycle } if let activeRunID { - let body = "\(result.message.title) - \(result.message.body)" - upsertToolMessage( + let body = lazuliPluginDetail(for: result.message) + recordToolEvent( runID: activeRunID, toolCallID: "lazuli-\(result.message.id)", - title: "Lazuli", + title: "Computer Control", + category: .plugin, body: body, state: result.message.kind == .error ? .failed : .final ) @@ -881,12 +2115,43 @@ public final class MissionControlStore: ObservableObject { append(ActivityMessage( id: result.message.id, kind: result.message.kind == .error ? .error : .tool, - title: "Lazuli", + title: "Computer Control", body: "\(result.message.title) - \(result.message.body)", createdAt: result.message.createdAt )) } + private func lazuliPluginDetail(for message: ActivityMessage) -> String { + let title = message.title.trimmingCharacters(in: .whitespacesAndNewlines) + if title.hasPrefix("Action: ") { + return GatewayStreamFormatter.toolBody(phase: "result", name: "lazuli_\(String(title.dropFirst("Action: ".count)))", data: [:]) + } + if title.hasPrefix("Done: ") { + return GatewayStreamFormatter.toolBody(phase: "result", name: "lazuli_\(String(title.dropFirst("Done: ".count)))", data: [:]) + } + if title.hasPrefix("Failed: ") { + return "\(GatewayStreamFormatter.toolBody(phase: "result", name: "lazuli_\(String(title.dropFirst("Failed: ".count)))", data: [:])) failed" + } + return [title, message.body] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " - ") + } + + private func appendAutomatedRuntimeMessageIfNeeded(_ text: String, runID: String, state: MessageStreamState) -> Bool { + guard isAutomatedRuntimeMessage(text) else { return false } + append(ActivityMessage( + id: "\(Date().timeIntervalSince1970)-system-\(runID)", + kind: .system, + title: automatedRuntimeTitle(for: text), + body: automatedRuntimeBody(for: text), + createdAt: Date(), + runId: runID, + streamState: state + )) + return true + } + private func eventBelongsToCurrentSession(_ payload: [String: Any]) -> Bool { guard let eventSessionKey = payload["sessionKey"] as? String else { return true } return eventSessionKey == sessionKey @@ -948,39 +2213,472 @@ public final class MissionControlStore: ObservableObject { )) } - private func upsertToolMessage(runID: String, toolCallID: String, title: String, body: String, state: MessageStreamState) { - let failed = state == .failed - if let id = toolMessageIDsByToolCallID[toolCallID], + private func recordToolEvent( + runID: String, + toolCallID: String, + title: String, + category: GatewayStreamFormatter.ToolCategory = .tool, + body: String, + state: MessageStreamState + ) { + var rollup = runToolRollupsByRunID[runID] ?? RunToolRollup() + if rollup.toolNamesByCallID[toolCallID] == nil { + rollup.toolNamesByCallID[toolCallID] = title + rollup.toolCounts[title, default: 0] += 1 + } + rollup.toolCategoriesByName[title] = category + + if state == .streaming { + rollup.activeTools.insert(title) + if category == .plugin { + rollup.activePlugins.insert(title) + } + } else { + rollup.activeTools.remove(title) + if category == .plugin { + rollup.activePlugins.remove(title) + rollup.latestEndedPlugin = title + } + } + if state == .failed { + rollup.failedTools.insert(title) + } + if category == .plugin, !body.isEmpty, rollup.pluginDetails.last != body { + rollup.pluginDetails.append(body) + } + if category == .tool && (state == .final || state == .failed) { + rollup.toolActionCounts[body, default: 0] += 1 + rollup.compactActionCounts[compactActionKey(for: body), default: 0] += 1 + rollup.toolDetails = formattedToolActionDetails(from: rollup.toolActionCounts) + } + rollup.latestSummary = body + runToolRollupsByRunID[runID] = rollup + + upsertWorkingSummary(runID: runID, latestSummary: body) + } + + private func closeToolSummarySegmentIfNeeded(runID: String) { + guard let rollup = runToolRollupsByRunID[runID], rollup.hasVisibleToolActivity else { return } + guard rollup.activeTools.isEmpty else { return } + finalizeWorkingSummary(runID: runID) + runToolRollupsByRunID.removeValue(forKey: runID) + workingMessageIDsByRunID.removeValue(forKey: runID) + } + + private func handleGatewayDisconnect() { + pendingRequests.values.forEach { callback in + callback(false, nil) + } + pendingRequests.removeAll() + + for runID in runToolRollupsByRunID.keys { + clearActiveToolsForDisconnect(runID: runID) + finalizeWorkingSummary(runID: runID) + removeEmptyWorkingSummary(runID: runID) + } + + if let activeRunID { + clearStatus(runID: activeRunID) + finishResponseRun(runID: activeRunID) + } + } + + private func clearActiveToolsForDisconnect(runID: String) { + guard var rollup = runToolRollupsByRunID[runID] else { return } + rollup.activeTools.removeAll() + rollup.activePlugins.removeAll() + runToolRollupsByRunID[runID] = rollup + + guard let id = workingMessageIDsByRunID[runID], + let index = messages.firstIndex(where: { $0.id == id }) + else { return } + + let startedAt = runStartedAtByRunID[runID] ?? messages[index].createdAt + messages[index] = ActivityMessage( + id: id, + kind: .workSummary, + title: workingSummaryTitle(for: runID, since: startedAt), + body: workingSummaryBody(for: runID), + createdAt: messages[index].createdAt, + runId: runID, + streamState: messages[index].streamState + ) + } + + private func removeEmptyWorkingSummary(runID: String) { + guard let id = workingMessageIDsByRunID[runID], + let index = messages.firstIndex(where: { $0.id == id }), + messages[index].body == "No tools used yet." + else { return } + messages.remove(at: index) + workingMessageIDsByRunID.removeValue(forKey: runID) + } + + private func finalizeWorkingSummary(runID: String) { + guard let id = workingMessageIDsByRunID[runID], + let index = messages.firstIndex(where: { $0.id == id }), + messages[index].body != "No tools used yet." + else { return } + + messages[index] = ActivityMessage( + id: id, + kind: .workSummary, + title: messages[index].title, + body: messages[index].body, + createdAt: messages[index].createdAt, + runId: runID, + streamState: .final + ) + } + + private func upsertWorkingSummary(runID: String, latestSummary: String? = nil) { + let startedAt = runStartedAtByRunID[runID] ?? Date() + runStartedAtByRunID[runID] = startedAt + + if latestSummary != nil { + var rollup = runToolRollupsByRunID[runID] ?? RunToolRollup() + rollup.latestSummary = latestSummary + runToolRollupsByRunID[runID] = rollup + } + responseTokenVisibleRunIDs.insert(runID) + if activeRunID == runID { + updateResponseMarkerStatus(runID: runID, isActive: responseMarkerStatus.isActive) + } + + let body = workingSummaryBody(for: runID) + guard body != "No tools used yet." else { return } + let title = workingSummaryTitle(for: runID, since: startedAt) + + if let id = workingMessageIDsByRunID[runID], let index = messages.firstIndex(where: { $0.id == id }) { messages[index] = ActivityMessage( id: id, - kind: failed ? .error : .tool, + kind: .workSummary, title: title, body: body, createdAt: messages[index].createdAt, runId: runID, - parentRunId: runID, - toolCallId: toolCallID, - streamState: state + streamState: .streaming ) return } - let id = "\(Date().timeIntervalSince1970)-tool-\(toolCallID)" - toolMessageIDsByToolCallID[toolCallID] = id + let id = "\(Date().timeIntervalSince1970)-worked-\(runID)" + workingMessageIDsByRunID[runID] = id append(ActivityMessage( id: id, - kind: failed ? .error : .tool, + kind: .workSummary, title: title, body: body, - createdAt: Date(), + createdAt: startedAt, runId: runID, - parentRunId: runID, - toolCallId: toolCallID, - streamState: state + streamState: .streaming )) } + private func workingSummaryBody(for runID: String) -> String { + guard let rollup = runToolRollupsByRunID[runID] else { + return "No tools used yet." + } + + var lines: [String] = [] + if !rollup.pluginDetails.isEmpty { + lines.append(contentsOf: rollup.pluginDetails) + } + if !rollup.toolDetails.isEmpty { + lines.append(contentsOf: rollup.toolDetails) + } + let activeTools = rollup.activeTools.subtracting(rollup.activePlugins) + if !activeTools.isEmpty { + lines.append("Active: \(activeTools.sorted().joined(separator: ", "))") + } + if !rollup.failedTools.isEmpty { + lines.append("Needs attention: \(rollup.failedTools.sorted().joined(separator: ", "))") + } + return lines.isEmpty ? "No tools used yet." : lines.joined(separator: "\n") + } + + private func formattedToolActionDetails(from counts: [String: Int]) -> [String] { + counts + .sorted { lhs, rhs in + if lhs.value == rhs.value { + return lhs.key < rhs.key + } + return lhs.value > rhs.value + } + .map { action, count in + count > 1 ? "\(action) x\(count)" : action + } + } + + private func workingSummaryTitle(for runID: String, since startedAt: Date) -> String { + guard let rollup = runToolRollupsByRunID[runID] else { + return "Worked for \(durationText(since: startedAt))" + } + if !rollup.activePlugins.isEmpty { + return "Using \(rollup.activePlugins.sorted().joined(separator: ", "))" + } + if let endedPlugin = rollup.latestEndedPlugin { + return "\(endedPlugin) ended" + } + if !rollup.compactActionCounts.isEmpty { + return formattedCompactActionTitle(from: rollup.compactActionCounts) + } + return "Worked for \(durationText(since: startedAt))" + } + + private func compactActionKey(for detail: String) -> String { + if detail.hasPrefix("Ran ") { + return "Ran Command" + } + if detail.hasPrefix("Edited ") { + return "Edited File" + } + if detail.hasPrefix("Read ") + || detail.hasPrefix("Searched ") + || detail.hasPrefix("Listed ") + || detail.hasPrefix("Explored ") { + return "Explored File" + } + return "Used Tool" + } + + private func formattedCompactActionTitle(from counts: [String: Int]) -> String { + counts + .sorted { lhs, rhs in + let order = ["Edited File": 0, "Ran Command": 1, "Explored File": 2, "Used Tool": 3] + let lhsOrder = order[lhs.key] ?? 99 + let rhsOrder = order[rhs.key] ?? 99 + if lhsOrder == rhsOrder { return lhs.key < rhs.key } + return lhsOrder < rhsOrder + } + .map { key, count in + count == 1 ? key : "\(key)s".replacingOccurrences(of: "Command", with: "Command") + " \(count)" + } + .map { value in + let parts = value.split(separator: " ") + guard parts.count >= 3, let count = Int(parts.last ?? "") else { return value } + let verb = parts.dropLast(2).joined(separator: " ") + let noun = parts.dropLast().last.map(String.init) ?? "" + return "\(verb) \(count) \(noun)" + } + .joined(separator: ", ") + } + + private func formattedToolCounts(in rollup: RunToolRollup, category: GatewayStreamFormatter.ToolCategory) -> String { + rollup.toolCounts + .filter { name, _ in + (rollup.toolCategoriesByName[name] ?? .tool) == category + } + .sorted { lhs, rhs in + if lhs.value == rhs.value { + return lhs.key < rhs.key + } + return lhs.value > rhs.value + } + .map { name, count in + count > 1 ? "\(name) x\(count)" : name + } + .joined(separator: ", ") + } + + private func durationText(since startedAt: Date) -> String { + let seconds = max(1, Int(Date().timeIntervalSince(startedAt).rounded())) + if seconds < 60 { + return "\(seconds)s" + } + let minutes = max(1, seconds / 60) + return "\(minutes)m" + } + + private func thinkingWord(for runID: String) -> String { + if let word = thinkingWordsByRunID[runID] { + return word + } + let word = Self.thinkingWords[thinkingWordCursor % Self.thinkingWords.count] + thinkingWordCursor += 1 + thinkingWordsByRunID[runID] = word + return word + } + + private func activateResponseRun(runID: String, resetTokens: Bool = false) { + let isNewRun = activeRunID != runID + activeRunID = runID + if runStartedAtByRunID[runID] == nil { + runStartedAtByRunID[runID] = Date() + } + if resetTokens || isNewRun { + responseTokenCount = 0 + responseTokenCountIsEstimated = false + responseTokenVisibleRunIDs.remove(runID) + } + updateResponseMarkerStatus(runID: runID, isActive: true) + startResponseStatusTicker() + } + + private func finishResponseRun(runID: String) { + updateResponseMarkerStatus(runID: runID, isActive: false) + if activeRunID == runID { + activeRunID = nil + } + responseStatusTask?.cancel() + responseStatusTask = nil + } + + private func markResponseTokensVisible(runID: String) { + responseTokenVisibleRunIDs.insert(runID) + updateResponseMarkerStatus(runID: runID, isActive: responseMarkerStatus.isActive) + } + + private func startResponseStatusTicker() { + guard responseStatusTask == nil else { return } + responseStatusTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + await MainActor.run { + guard let self, let runID = self.activeRunID else { return } + self.updateResponseMarkerStatus(runID: runID, isActive: true) + if let id = self.statusMessageIDsByRunID[runID], + let index = self.messages.firstIndex(where: { $0.id == id }) { + self.messages[index] = ActivityMessage( + id: id, + kind: self.messages[index].kind, + title: self.thinkingWord(for: runID), + body: self.messages[index].body, + createdAt: self.messages[index].createdAt, + runId: runID, + streamState: self.messages[index].streamState + ) + } + if let id = self.workingMessageIDsByRunID[runID], + let index = self.messages.firstIndex(where: { $0.id == id }), + let startedAt = self.runStartedAtByRunID[runID] { + self.messages[index] = ActivityMessage( + id: id, + kind: .workSummary, + title: self.workingSummaryTitle(for: runID, since: startedAt), + body: self.messages[index].body, + createdAt: self.messages[index].createdAt, + runId: runID, + streamState: self.messages[index].streamState + ) + } + } + } + } + } + + private func updateResponseMarkerStatus(runID: String, isActive: Bool) { + let startedAt = runStartedAtByRunID[runID] ?? Date() + responseMarkerStatus = ResponseMarkerStatus( + isActive: isActive, + phaseText: responseTokenVisibleRunIDs.contains(runID) ? "Working" : thinkingWord(for: runID), + elapsedText: durationText(since: startedAt), + tokenText: responseTokenText, + showsTokens: responseTokenVisibleRunIDs.contains(runID) + ) + } + + private static let thinkingWords = [ + "Humanating", + "Germinating", + "Shaping", + "Orienting", + "Composing", + "Listening", + "Calibrating", + "Weaving", + "Untangling", + ] + + private var responseTokenText: String { + if responseTokenCount <= 0 { + return "0 tokens" + } + let formatted = Self.shortCount(responseTokenCount) + return responseTokenCountIsEstimated ? "~\(formatted) tokens" : "\(formatted) tokens" + } + + private func updateEstimatedResponseTokens(from text: String) { + guard responseTokenCountIsEstimated || responseTokenCount == 0 else { + if let activeRunID { + updateResponseMarkerStatus(runID: activeRunID, isActive: responseMarkerStatus.isActive) + } + return + } + let estimate = max(1, Int(ceil(Double(text.count) / 4.0))) + if estimate >= responseTokenCount { + responseTokenCount = estimate + responseTokenCountIsEstimated = true + } + if let activeRunID { + updateResponseMarkerStatus(runID: activeRunID, isActive: responseMarkerStatus.isActive) + } + } + + private func updateResponseTokens(from payload: [String: Any]) { + guard let tokens = tokenCount(from: payload), tokens > 0 else { return } + responseTokenCount = tokens + responseTokenCountIsEstimated = false + if let activeRunID { + updateResponseMarkerStatus(runID: activeRunID, isActive: responseMarkerStatus.isActive) + } + } + + private func tokenCount(from payload: [String: Any]) -> Int? { + let containers: [[String: Any]?] = [ + payload["usage"] as? [String: Any], + payload["tokenUsage"] as? [String: Any], + payload["modelUsage"] as? [String: Any], + (payload["message"] as? [String: Any])?["usage"] as? [String: Any], + (payload["data"] as? [String: Any])?["usage"] as? [String: Any], + (payload["data"] as? [String: Any])?["tokenUsage"] as? [String: Any], + ] + + for container in containers.compactMap({ $0 }) { + if let exact = numberValue( + in: container, + keys: ["totalTokens", "total_tokens", "tokens", "total"] + ) { + return exact + } + let input = numberValue(in: container, keys: ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ?? 0 + let output = numberValue(in: container, keys: ["outputTokens", "output_tokens", "completionTokens", "completion_tokens"]) ?? 0 + if input + output > 0 { + return input + output + } + } + return nil + } + + private func numberValue(in dictionary: [String: Any], keys: [String]) -> Int? { + for key in keys { + if let value = dictionary[key] { + if let int = value as? Int { + return int + } + if let double = value as? Double { + return Int(double.rounded()) + } + if let string = value as? String, let int = Int(string) { + return int + } + } + } + return nil + } + + private static func shortCount(_ value: Int) -> String { + if value < 1_000 { + return "\(value)" + } + let thousands = Double(value) / 1_000.0 + if thousands < 10 { + return String(format: "%.1fk", thousands) + } + return "\(Int(thousands.rounded()))k" + } + private func finishRun(_ runID: String, state: MessageStreamState) { if let id = assistantMessageIDsByRunID[runID], let index = messages.firstIndex(where: { $0.id == id }) { diff --git a/Sources/Halos/Support/HalosStorage.swift b/Sources/Halos/Support/HalosStorage.swift new file mode 100644 index 0000000..a85a1cc --- /dev/null +++ b/Sources/Halos/Support/HalosStorage.swift @@ -0,0 +1,52 @@ +import Foundation + +enum HalosStorage { + static let rootURL: URL = { + if let override = ProcessInfo.processInfo.environment["HALOS_STORAGE_ROOT"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return URL(fileURLWithPath: "/Volumes/Thorium/Storage/Halos", isDirectory: true) + }() + + static var cacheURL: URL { + rootURL.appending(path: "Cache", directoryHint: .isDirectory) + } + + static var attachmentsURL: URL { + cacheURL.appending(path: "Attachments", directoryHint: .isDirectory) + } + + static var stateURL: URL { + rootURL.appending(path: "State", directoryHint: .isDirectory) + } + + static var gatewayURL: URL { + stateURL.appending(path: "Gateway", directoryHint: .isDirectory) + } + + static var gatewayDeviceURL: URL { + gatewayURL.appending(path: "gateway-device.json") + } + + static func ensureLayout() throws { + let fileManager = FileManager.default + for directory in [cacheURL, attachmentsURL, stateURL, gatewayURL] { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + } + + static func migrateFile(from legacyURL: URL, to targetURL: URL) { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: legacyURL.path), + !fileManager.fileExists(atPath: targetURL.path) + else { + return + } + do { + try fileManager.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.moveItem(at: legacyURL, to: targetURL) + } catch { + try? fileManager.copyItem(at: legacyURL, to: targetURL) + } + } +} diff --git a/Sources/Halos/Views/ActivityListView.swift b/Sources/Halos/Views/ActivityListView.swift index c063f5f..c18fff4 100644 --- a/Sources/Halos/Views/ActivityListView.swift +++ b/Sources/Halos/Views/ActivityListView.swift @@ -2,7 +2,13 @@ import SwiftUI struct ActivityListView: View { let messages: [ActivityMessage] + let resolveVeyraApproval: (String, VeyraApprovalDecision) -> Void @State private var emptyCopy = EmptyComposerCopy.today() + @State private var isPinnedToBottom = true + @State private var viewportHeight: CGFloat = 0 + + private let bottomAnchorID = "activity-list-bottom-anchor" + private let bottomFollowThreshold: CGFloat = 48 var body: some View { ScrollViewReader { proxy in @@ -12,26 +18,93 @@ struct ActivityListView: View { emptyState } else { ForEach(messages) { message in - ActivityRowView(message: message) + ActivityRowView(message: message, resolveVeyraApproval: resolveVeyraApproval) .id(message.id) } } + + Color.clear + .frame(height: 1) + .id(bottomAnchorID) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: ActivityListBottomOffsetPreferenceKey.self, + value: geometry.frame(in: .named(ActivityListCoordinateSpace.name)).maxY + ) + } + } } - .padding(.vertical, 1) + .padding(.top, 52) + .padding(.bottom, 1) .padding(.horizontal, 18) } + .coordinateSpace(name: ActivityListCoordinateSpace.name) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: ActivityListViewportHeightPreferenceKey.self, + value: geometry.size.height + ) + } + } .scrollIndicators(.hidden) + .transaction { transaction in + transaction.animation = nil + } + .mask { + VStack(spacing: 0) { + LinearGradient( + colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 42) + + Color.black + } + } .onAppear { if messages.isEmpty { emptyCopy = EmptyComposerCopy.today() } + scrollToBottom(proxy, animated: false) } - .onChange(of: messages.last?.id) { _, id in - guard let id else { return } - withAnimation(.smooth(duration: 0.22)) { - proxy.scrollTo(id, anchor: .bottom) - } + .onPreferenceChange(ActivityListViewportHeightPreferenceKey.self) { height in + viewportHeight = height + } + .onPreferenceChange(ActivityListBottomOffsetPreferenceKey.self) { bottomOffset in + guard viewportHeight > 0 else { return } + isPinnedToBottom = bottomOffset <= viewportHeight + bottomFollowThreshold + } + .onChange(of: contentFingerprint) { _, _ in + guard isPinnedToBottom else { return } + scrollToBottom(proxy, animated: true) + } + } + } + + private var contentFingerprint: String { + messages + .map { message in + [ + message.id, + message.title, + message.body, + message.streamState.rawValue, + ].joined(separator: "\u{1F}") } + .joined(separator: "\u{1E}") + } + + private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool) { + guard !messages.isEmpty else { return } + if animated { + withAnimation(.smooth(duration: 0.18)) { + proxy.scrollTo(bottomAnchorID, anchor: .bottom) + } + } else { + proxy.scrollTo(bottomAnchorID, anchor: .bottom) } } @@ -51,6 +124,26 @@ struct ActivityListView: View { } } +private enum ActivityListCoordinateSpace { + static let name = "activity-list-scroll" +} + +private struct ActivityListBottomOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +private struct ActivityListViewportHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + private struct EmptyComposerCopy: Equatable { let title: String let subtitle: String diff --git a/Sources/Halos/Views/ActivityRowView.swift b/Sources/Halos/Views/ActivityRowView.swift index 6642438..bbb4390 100644 --- a/Sources/Halos/Views/ActivityRowView.swift +++ b/Sources/Halos/Views/ActivityRowView.swift @@ -2,24 +2,46 @@ import SwiftUI struct ActivityRowView: View { let message: ActivityMessage + let resolveVeyraApproval: ((String, VeyraApprovalDecision) -> Void)? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var userChangedWorkSummaryExpansion = false + @State private var isWorkSummaryExpanded = true + + init( + message: ActivityMessage, + resolveVeyraApproval: ((String, VeyraApprovalDecision) -> Void)? = nil + ) { + self.message = message + self.resolveVeyraApproval = resolveVeyraApproval + } var body: some View { + if let approval = message.veyraApproval { + veyraApprovalCard(approval) + } else { switch message.kind { case .user: userBubble case .assistant: assistantMessage - case .tool, .debug, .system: + case .workSummary: + workSummary + case .tool, .debug: statusMessage + case .system: + if message.streamState == .streaming { + thinkingStatus + } else { + systemNotice + } case .error: errorMessage } + } } private var userBubble: some View { HStack { - Spacer(minLength: 72) - Text(message.body) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) @@ -29,12 +51,14 @@ struct ActivityRowView: View { .padding(.vertical, 9) .background(userBubbleFill, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .textSelection(.enabled) + + Spacer(minLength: 72) } - .frame(maxWidth: .infinity, alignment: .trailing) + .frame(maxWidth: .infinity, alignment: .leading) } private var assistantMessage: some View { - Text(message.body) + markdownText(message.body) .font(.system(size: 13, weight: .regular)) .foregroundStyle(HalosTheme.primaryText) .lineSpacing(4) @@ -62,6 +86,138 @@ struct ActivityRowView: View { .padding(.vertical, 2) } + private var systemNotice: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText.opacity(0.85)) + .frame(width: 14, height: 18) + + VStack(alignment: .leading, spacing: 3) { + Text(message.title) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + + if !message.body.isEmpty { + Text(message.body) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(HalosTheme.rowFill.opacity(0.55), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(HalosTheme.faintSeparator.opacity(0.75), lineWidth: 1) + } + .frame(maxWidth: 520, alignment: .leading) + .textSelection(.enabled) + } + + private func veyraApprovalCard(_ approval: VeyraApprovalCard) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "checkmark.seal") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 3) { + Text(message.title) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + + Text("\(approval.providerId.uppercased()) \(approval.kind) · \(approval.riskLevel) risk · \(approval.createdAtText)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText) + } + + Spacer(minLength: 0) + } + + if let title = approval.title, !title.isEmpty { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + .lineLimit(2) + } + + Text(approval.body) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(HalosTheme.primaryText) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + if approval.status == "pending" { + HStack(spacing: 8) { + veyraActionButton(title: "Approve", symbol: "checkmark", decision: .approve, approvalId: approval.approvalId) + veyraActionButton(title: "Deny", symbol: "xmark", decision: .deny, approvalId: approval.approvalId) + veyraActionButton(title: "Ask", symbol: "text.bubble", decision: .ask, approvalId: approval.approvalId) + Spacer(minLength: 0) + } + } else { + Text("Resolved: \(approval.status)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background(HalosTheme.rowFill.opacity(0.68), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HalosTheme.faintSeparator.opacity(0.82), lineWidth: 1) + } + .frame(maxWidth: 540, alignment: .leading) + } + + private func veyraActionButton(title: String, symbol: String, decision: VeyraApprovalDecision, approvalId: String) -> some View { + Button { + resolveVeyraApproval?(approvalId, decision) + } label: { + Label(title, systemImage: symbol) + .font(.system(size: 11, weight: .semibold)) + .labelStyle(.titleAndIcon) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.white.opacity(0.075), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(HalosTheme.faintSeparator.opacity(0.75), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(resolveVeyraApproval == nil) + } + + private var thinkingStatus: some View { + HStack(alignment: .center, spacing: 7) { + HalosThinkingEyesView(isActive: !reduceMotion) + .accessibilityHidden(true) + + ShimmeringStatusText(text: message.title, isActive: !reduceMotion) + .font(.system(size: 12, weight: .semibold)) + + if !message.body.isEmpty { + Text(message.body) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText.opacity(0.72)) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.vertical, 3) + .accessibilityLabel("\(message.title). \(message.body)") + } + private var errorMessage: some View { HStack(alignment: .firstTextBaseline, spacing: 7) { Image(systemName: "exclamationmark.triangle") @@ -81,7 +237,7 @@ struct ActivityRowView: View { } private var userBubbleFill: Color { - Color(red: 0.035, green: 0.170, blue: 0.295) + Color.white.opacity(0.12) } private var errorTextColor: Color { @@ -100,6 +256,8 @@ struct ActivityRowView: View { return "sparkles" case .user: return "person.fill" + case .workSummary: + return "chevron.down" case .system: return "info.circle" case .tool: @@ -110,4 +268,428 @@ struct ActivityRowView: View { return "exclamationmark.triangle" } } + + private var workSummary: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + withAnimation(.smooth(duration: 0.14)) { + userChangedWorkSummaryExpansion = true + isWorkSummaryExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Text(message.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(HalosTheme.tertiaryText) + .rotationEffect(.degrees(isWorkSummaryExpanded ? 0 : -90)) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isWorkSummaryExpanded { + VStack(alignment: .leading, spacing: 7) { + ForEach(workSummaryLines, id: \.self) { line in + markdownText(line) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.leading, 1) + } + } + .onAppear { + syncWorkSummaryExpansionIfNeeded() + } + .onChange(of: message.streamState) { _, _ in + syncWorkSummaryExpansionIfNeeded() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var workSummaryLines: [String] { + message.body + .split(separator: "\n", omittingEmptySubsequences: true) + .map(String.init) + } + + private func syncWorkSummaryExpansionIfNeeded() { + guard message.kind == .workSummary, !userChangedWorkSummaryExpansion else { return } + isWorkSummaryExpanded = message.streamState == .streaming + } + + private func markdownText(_ source: String) -> Text { + if let attributed = try? AttributedString( + markdown: source, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return Text(attributed) + } + return Text(source) + } +} + +struct HalosThinkingEyesView: View { + let isActive: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var startDate: Date? + + private let eyeDiameter: CGFloat = 9 + private let eyeGap: CGFloat = 1.25 + private let pupilRatio: CGFloat = 0.31 + private let pupilOffsetRatio: Double = 0.42 + private let blinkInterval: Double = 4.0 + private let blinkJitter: Double = 0.20 + private let blinkJitterFreq: Double = 0.27 + private let blinkDurationFraction: Double = 0.038 + + private var totalWidth: CGFloat { + eyeDiameter * 2 + eyeGap + } + + var body: some View { + Group { + if isActive && !reduceMotion { + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + let elapsed = startDate.map { timeline.date.timeIntervalSince($0) } ?? 0 + eyes(elapsed: elapsed) + } + .onAppear { + if startDate == nil { + startDate = Date() + } + } + } else { + eyes(elapsed: 0) + } + } + .frame(width: totalWidth, height: eyeDiameter) + } + + private func eyes(elapsed: TimeInterval) -> some View { + let eyeRadius = eyeDiameter / 2 + let pupilDiameter = eyeDiameter * pupilRatio + let offsetMax = Double(eyeRadius) * pupilOffsetRatio + let position = pupilPosition(elapsed: elapsed) + let pupilX = CGFloat(position.x * offsetMax) + let pupilY = CGFloat(position.y * offsetMax) + let blink = blinkAmount(elapsed: elapsed) + let pupilScaleY = max(0.42, 1 - blink * 0.58) + let leftCenter = -(eyeRadius + eyeGap / 2) + let rightCenter = eyeRadius + eyeGap / 2 + + return ZStack { + Circle() + .fill(.white) + .frame(width: eyeDiameter, height: eyeDiameter) + .offset(x: leftCenter) + Circle() + .fill(.black) + .frame(width: pupilDiameter, height: pupilDiameter) + .scaleEffect(x: 1, y: CGFloat(pupilScaleY)) + .offset(x: leftCenter + pupilX, y: pupilY) + + Circle() + .fill(.white) + .frame(width: eyeDiameter, height: eyeDiameter) + .offset(x: rightCenter) + Circle() + .fill(.black) + .frame(width: pupilDiameter, height: pupilDiameter) + .scaleEffect(x: 1, y: CGFloat(pupilScaleY)) + .offset(x: rightCenter + pupilX, y: pupilY) + } + } + + private static let states: [(side: Double, holdDur: Double, swoopDur: Double)] = [ + (-1, 0.5, 1.0), + ( 1, 1.0, 0.9), + (-1, 0.7, 1.2), + ( 1, 0.4, 1.1), + (-1, 1.3, 0.8), + ( 1, 0.6, 1.4), + (-1, 0.9, 1.0), + ( 1, 1.1, 0.9), + (-1, 0.6, 1.2), + ( 1, 0.8, 1.0), + (-1, 0.5, 1.3), + ( 1, 1.2, 0.9), + (-1, 0.7, 1.1), + ( 1, 0.5, 1.0), + ] + + private static let totalCycle = states.reduce(0) { $0 + $1.holdDur + $1.swoopDur } + + private func pupilPosition(elapsed: TimeInterval) -> (x: Double, y: Double) { + var t = elapsed.truncatingRemainder(dividingBy: Self.totalCycle) + if t < 0 { + t += Self.totalCycle + } + + var accumulated = 0.0 + for state in Self.states { + if t < accumulated + state.holdDur { + return (x: state.side, y: 0) + } + + accumulated += state.holdDur + if t < accumulated + state.swoopDur { + let progress = (t - accumulated) / state.swoopDur + let eased = Self.smoothstep(progress) + let x = state.side + (-2 * state.side) * eased + let y = 4 * eased * (1 - eased) + return (x: x, y: y) + } + + accumulated += state.swoopDur + } + + return (x: 0, y: 0) + } + + private static func smoothstep(_ value: Double) -> Double { + value * value * (3 - 2 * value) + } + + private func blinkAmount(elapsed: TimeInterval) -> Double { + let phase = elapsed / blinkInterval + blinkJitter * sin(elapsed * blinkJitterFreq) + let local = phase - floor(phase) + if local < blinkDurationFraction { + let t = local / blinkDurationFraction + return sin(t * .pi) + } + return 0 + } +} + +struct ShimmeringStatusText: View { + let text: String + let isActive: Bool + + var body: some View { + if isActive { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + shimmer(elapsed: timeline.date.timeIntervalSinceReferenceDate) + } + } else { + Text(text) + .foregroundStyle(HalosTheme.tertiaryText) + } + } + + private func shimmer(elapsed: TimeInterval) -> some View { + let progress = elapsed.truncatingRemainder(dividingBy: 2.2) / 2.2 + return Text(text) + .foregroundStyle(HalosTheme.tertiaryText.opacity(0.72)) + .overlay { + GeometryReader { proxy in + let width = max(1, proxy.size.width) + LinearGradient( + stops: [ + .init(color: .clear, location: 0.00), + .init(color: .white.opacity(0.08), location: 0.30), + .init(color: .white.opacity(0.70), location: 0.50), + .init(color: .white.opacity(0.08), location: 0.70), + .init(color: .clear, location: 1.00), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width * 0.62) + .offset(x: CGFloat(progress) * width * 1.6 - width * 0.5) + } + .mask(Text(text)) + } + } +} + +struct HalosResponseMarkView: View { + let isActive: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var startDate: Date? + @State private var isFinishing = false + @State private var finishStartDate: Date? + @State private var finishDuration: TimeInterval = 0.35 + @State private var finishTask: Task? + + private let glyphSize: CGFloat = 20 + private let breatheCycle: TimeInterval = 1.0 + private let bloomEnd: Double = 0.38 + private let holdEnd: Double = 0.62 + private let contractEnd: Double = 0.83 + private let tightRadiusFraction: CGFloat = 0.22 + private let fullRadiusFraction: CGFloat = 1.05 + private let spinHz: Double = 1.5 + private let radiusPulsePeriod: Double = 3.0 + private let radiusPulseAmp: Double = 0.08 + + var body: some View { + let shouldAnimate = (isActive || isFinishing) && !reduceMotion + + Group { + if shouldAnimate { + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + let elapsed = startDate.map { timeline.date.timeIntervalSince($0) } ?? 0 + activeGlyph(elapsed: elapsed, date: timeline.date) + } + .onAppear { + if startDate == nil { + startDate = Date() + } + } + } else { + restingGlyph + } + } + .onChange(of: isActive) { _, newValue in + if newValue { + startActiveAnimation() + } else { + finishActiveAnimation() + } + } + .onDisappear { + finishTask?.cancel() + finishTask = nil + } + .foregroundStyle(.white) + .frame(width: glyphSize, height: glyphSize) + } + + private var restingGlyph: some View { + let centers = HalosDotMarkGeometry.centers(in: glyphSize) + let baseDiameter = HalosDotMarkGeometry.radius(in: glyphSize) * 2 + + return ZStack { + ForEach(0..<7, id: \.self) { index in + Circle() + .fill(.white) + .frame(width: baseDiameter, height: baseDiameter) + .shadow(color: .white.opacity(0.35), radius: max(1.0, baseDiameter * 0.42)) + .offset(x: centers[index].x, y: centers[index].y) + } + } + } + + private func activeGlyph(elapsed: TimeInterval, date: Date) -> some View { + let phase = elapsed.truncatingRemainder(dividingBy: breatheCycle) / breatheCycle + let bloom = bloomStrength(phase: phase) + let centers = HalosDotMarkGeometry.centers(in: glyphSize) + let baseDiameter = HalosDotMarkGeometry.radius(in: glyphSize) * 2 + let step = abs(centers[2].x) + let pulseFactor = 1 + radiusPulseAmp * sin(elapsed * 2 * .pi / radiusPulsePeriod) + let tightRadius = step * tightRadiusFraction + let fullRadius = step * fullRadiusFraction * CGFloat(pulseFactor) + let ringRadius = tightRadius + (fullRadius - tightRadius) * CGFloat(bloom) + let angle = elapsed * 2 * .pi * spinHz + let glowOpacity = 0.50 - 0.15 * bloom + let glowRadius = CGFloat(2.5 - 1.0 * bloom) + let finishProgress = finishProgress(at: date) + let points = markPoints( + staticCenters: centers, + ringRadius: ringRadius, + angle: angle, + finishProgress: finishProgress + ) + + return ZStack { + ForEach(0..<7, id: \.self) { index in + Circle() + .fill(.white) + .frame(width: baseDiameter, height: baseDiameter) + .shadow(color: .white.opacity(glowOpacity), radius: glowRadius) + .offset(x: points[index].x, y: points[index].y) + } + } + } + + private func markPoints(staticCenters: [CGPoint], ringRadius: CGFloat, angle: Double, finishProgress: Double) -> [CGPoint] { + let staticToRingIndex = [0: 0, 1: 1, 4: 2, 6: 3, 5: 4, 2: 5] + let blend = CGFloat(Self.smoothstep(finishProgress)) + + return staticCenters.enumerated().map { index, staticPoint in + guard index != 3, let ringIndex = staticToRingIndex[index] else { + return staticPoint + } + let outerAngle = -Double.pi / 2 + Double(ringIndex) * Double.pi / 3 + angle + let ringPoint = CGPoint( + x: CGFloat(cos(outerAngle)) * ringRadius, + y: CGFloat(sin(outerAngle)) * ringRadius + ) + return CGPoint( + x: ringPoint.x + (staticPoint.x - ringPoint.x) * blend, + y: ringPoint.y + (staticPoint.y - ringPoint.y) * blend + ) + } + } + + private func finishProgress(at date: Date) -> Double { + guard isFinishing, let finishStartDate else { + return 0 + } + return min(1, max(0, date.timeIntervalSince(finishStartDate) / finishDuration)) + } + + private func bloomStrength(phase: Double) -> Double { + if phase < bloomEnd { + return Self.smoothstep(phase / bloomEnd) + } + if phase < holdEnd { + return 1 + } + if phase < contractEnd { + return 1 - Self.smoothstep((phase - holdEnd) / (contractEnd - holdEnd)) + } + return 0 + } + + private static func smoothstep(_ value: Double) -> Double { + value * value * (3 - 2 * value) + } + + private func startActiveAnimation() { + finishTask?.cancel() + finishTask = nil + isFinishing = false + finishStartDate = nil + startDate = Date() + } + + private func finishActiveAnimation() { + guard !reduceMotion, let startDate else { + isFinishing = false + finishStartDate = nil + self.startDate = nil + return + } + + finishTask?.cancel() + isFinishing = true + finishStartDate = Date() + + let elapsed = Date().timeIntervalSince(startDate) + let phase = elapsed.truncatingRemainder(dividingBy: breatheCycle) + let remainingCycle = breatheCycle - phase + let finishDelay = min(breatheCycle, max(0.24, remainingCycle)) + finishDuration = finishDelay + + finishTask = Task { + try? await Task.sleep(for: .seconds(finishDelay)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard !isActive else { return } + isFinishing = false + finishStartDate = nil + self.startDate = nil + finishTask = nil + } + } + } } diff --git a/Sources/Halos/Views/HalosControlWindowView.swift b/Sources/Halos/Views/HalosControlWindowView.swift index d6ed957..8d27157 100644 --- a/Sources/Halos/Views/HalosControlWindowView.swift +++ b/Sources/Halos/Views/HalosControlWindowView.swift @@ -7,9 +7,10 @@ public struct HalosControlWindowView: View { @State private var sidebarState: SidebarPanelState = .panelShown @State private var isSearchPresented = false @State private var searchQuery = "" + @Namespace private var headerTransitionNamespace - private var shouldReserveClosedRailSpace: Bool { - sidebarState != .panelShown && store.selectedPage != .code + private var mainContentTopPadding: CGFloat { + sidebarState == .panelShown ? 12 : 56 } public init(store: MissionControlStore) { @@ -37,8 +38,8 @@ public struct HalosControlWindowView: View { mainContent .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, shouldReserveClosedRailSpace ? 56 : 12) - .padding(.bottom, 12) + .padding(.top, mainContentTopPadding) + .padding(.bottom, 10) .padding(.trailing, 12) .padding(.leading, 0) } @@ -50,11 +51,11 @@ public struct HalosControlWindowView: View { ClosedSidebarSurface( store: store, state: $sidebarState, - openSearch: openSearch + openSearch: openSearch, + headerNamespace: headerTransitionNamespace ) .zIndex(3) .ignoresSafeArea(.container, edges: .top) - .transition(.opacity) } if isSearchPresented { @@ -71,6 +72,9 @@ public struct HalosControlWindowView: View { .transition(.opacity) } } + .onExitCommand { + handleEscapeCommand() + } } private func openSearch() { @@ -80,18 +84,40 @@ public struct HalosControlWindowView: View { } } + private func handleEscapeCommand() { + if isSearchPresented { + withAnimation(.smooth(duration: 0.16)) { + isSearchPresented = false + } + return + } + + guard store.isResponseMarkerActive || store.lazuliLifecycle != .idle else { return } + store.requestStop() + } + @ViewBuilder private var mainContent: some View { switch store.selectedPage { case .code: - CodePageView(store: store) + CodePageView( + store: store, + isSidebarClosed: sidebarState != .panelShown, + headerNamespace: headerTransitionNamespace + ) case .automations: AutomationsPageView( automations: store.automations, - selectedAutomationID: $store.selectedAutomationID + selectedAutomationID: $store.selectedAutomationID, + isSidebarClosed: sidebarState != .panelShown, + headerNamespace: headerTransitionNamespace ) case .tasks: - CodePageView(store: store) + CodePageView( + store: store, + isSidebarClosed: sidebarState != .panelShown, + headerNamespace: headerTransitionNamespace + ) case .settings: SettingsPageView( gatewayConnection: store.gatewayConnection, @@ -107,6 +133,45 @@ private enum SidebarPanelState: Equatable { case panelClosed } +private struct ClosedBreadcrumbText: Equatable { + let primary: String + let secondary: String? +} + +private struct CollapsingHeaderTitle: View { + let title: String + let id: String + let namespace: Namespace.ID + let isCompact: Bool + let color: Color + + private var compactScale: CGFloat { + 12.5 / 28 + } + + private var compactWidth: CGFloat { + switch title { + case "Automations": + return 78 + case "Settings": + return 48 + default: + return 34 + } + } + + var body: some View { + Text(title) + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(color) + .lineLimit(1) + .fixedSize() + .scaleEffect(isCompact ? compactScale : 1, anchor: .leading) + .frame(width: isCompact ? compactWidth : nil, height: isCompact ? 24 : 29, alignment: .leading) + .matchedGeometryEffect(id: id, in: namespace, properties: .position, anchor: .leading) + } +} + private struct SidebarContainerView: View { @ObservedObject var store: MissionControlStore let closeSidebar: () -> Void @@ -173,6 +238,7 @@ private struct ClosedSidebarSurface: View { @ObservedObject var store: MissionControlStore @Binding var state: SidebarPanelState let openSearch: () -> Void + let headerNamespace: Namespace.ID @State private var isHoveringRail = false @State private var isHoveringPreview = false @State private var isPreviewShown = false @@ -234,6 +300,65 @@ private struct ClosedSidebarSurface: View { } .buttonStyle(.borderless) .help("Search") + + closedBreadcrumb + .padding(.leading, 2) + } + .animation(.smooth(duration: 0.18), value: closedBreadcrumbText) + } + + private var closedBreadcrumb: some View { + HStack(spacing: 7) { + CollapsingHeaderTitle( + title: closedBreadcrumbText.primary, + id: closedBreadcrumbID, + namespace: headerNamespace, + isCompact: true, + color: HalosTheme.primaryText + ) + + if let secondary = closedBreadcrumbText.secondary { + Image(systemName: "chevron.right") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + + Text(secondary) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + } + } + .frame(height: 24) + .frame(maxWidth: 460, alignment: .leading) + } + + private var closedBreadcrumbID: String { + switch store.selectedPage { + case .code, .tasks: + return "code-title" + case .automations: + return "automations-title" + case .settings: + return "settings-title" + } + } + + private var closedBreadcrumbText: ClosedBreadcrumbText { + switch store.selectedPage { + case .code, .tasks: + guard !store.isCodeSessionBrowserPresented else { + return ClosedBreadcrumbText(primary: "Code", secondary: nil) + } + let preview = store.currentSessionSummary.preview.trimmingCharacters(in: .whitespacesAndNewlines) + return ClosedBreadcrumbText(primary: "Code", secondary: preview.isEmpty ? "New session" : preview) + case .automations: + if let id = store.selectedAutomationID, + let automation = store.automations.first(where: { $0.id == id }) { + return ClosedBreadcrumbText(primary: "Automations", secondary: automation.name) + } + return ClosedBreadcrumbText(primary: "Automations", secondary: nil) + case .settings: + return ClosedBreadcrumbText(primary: "Settings", secondary: nil) } } @@ -481,9 +606,6 @@ private struct PageIcon: View { case .automations: HalosGlyph(kind: .automationOrbit) .foregroundStyle(color) - case .settings: - HalosGlyph(kind: .settingsDots) - .foregroundStyle(color) default: Image(systemName: page.symbolName) .font(.system(size: 12, weight: .semibold)) @@ -492,76 +614,6 @@ private struct PageIcon: View { } } -private enum HalosGlyphKind { - case settingsDots - case automationOrbit -} - -private struct HalosGlyph: View { - let kind: HalosGlyphKind - - var body: some View { - GeometryReader { proxy in - let size = min(proxy.size.width, proxy.size.height) - ZStack { - switch kind { - case .settingsDots: - settingsDots(size: size) - case .automationOrbit: - automationOrbit(size: size) - } - } - .frame(width: proxy.size.width, height: proxy.size.height) - } - .aspectRatio(1, contentMode: .fit) - } - - private func settingsDots(size: CGFloat) -> some View { - let step = size * 0.282 - let r = step * (88.0 / 210.0) - let dots = [ - CGPoint(x: -step / 2, y: -step), - CGPoint(x: step / 2, y: -step), - CGPoint(x: -step, y: 0), - CGPoint(x: 0, y: 0), - CGPoint(x: step, y: 0), - CGPoint(x: -step / 2, y: step), - CGPoint(x: step / 2, y: step), - ] - - return ZStack { - ForEach(Array(dots.enumerated()), id: \.offset) { _, point in - Circle() - .frame(width: r * 2, height: r * 2) - .shadow(color: .white.opacity(0.14), radius: size * 0.028) - .offset(x: point.x, y: point.y) - } - } - } - - private func automationOrbit(size: CGFloat) -> some View { - let r = size * 0.115 - return ZStack { - Circle() - .stroke(lineWidth: max(1, size * 0.075)) - .opacity(0.46) - .frame(width: size * 0.68, height: size * 0.68) - - Circle() - .frame(width: r * 2, height: r * 2) - .offset(x: -size * 0.22, y: -size * 0.22) - - Circle() - .frame(width: r * 2, height: r * 2) - .offset(x: size * 0.26, y: -size * 0.04) - - Circle() - .frame(width: r * 2, height: r * 2) - .offset(x: -size * 0.08, y: size * 0.26) - } - } -} - private struct GlobalSearchOverlay: View { @ObservedObject var store: MissionControlStore @Binding var query: String @@ -728,8 +780,6 @@ private struct SearchResultIcon: View { switch symbolName { case "halos.automation": HalosGlyph(kind: .automationOrbit) - case "halos.settings": - HalosGlyph(kind: .settingsDots) default: Image(systemName: symbolName) .font(.system(size: 12, weight: .semibold)) @@ -738,9 +788,18 @@ private struct SearchResultIcon: View { } private struct CodePageView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion @ObservedObject var store: MissionControlStore + let isSidebarClosed: Bool + let headerNamespace: Namespace.ID @State private var workbenchLayout: [[OpenClawViewMode]] = [] + @State private var workbenchColumnWidths: [CGFloat] = [] @State private var draggingWorkbenchMode: OpenClawViewMode? + @State private var composerAttachments: [ComposerAttachmentItem] = [] + @State private var isChatDropTargeted = false + + private let workbenchDefaultWidth: CGFloat = 316 + private let chatReadableMaxWidth: CGFloat = 760 private var openWorkbenchModes: [OpenClawViewMode] { workbenchLayout.flatMap { $0 } @@ -762,35 +821,164 @@ private struct CodePageView: View { } private var chatColumn: some View { - VStack(spacing: 10) { - HStack { - Spacer(minLength: 0) + chatReadableColumn + .contentShape(Rectangle()) + .onDrop( + of: ComposerAttachment.acceptedTypeIdentifiers, + isTargeted: $isChatDropTargeted, + perform: attachProviders + ) + .onAppear { + store.refreshHalosSessions() + } + } - if openWorkbenchModes.isEmpty { - ViewsMenuButton( - openModes: openWorkbenchModes, - select: { mode in - store.selectedViewMode = mode - toggleWorkbench(mode) - } - ) - } + private var chatReadableColumn: some View { + VStack(spacing: 10) { + if !isSidebarClosed { + codeToolbar } - .frame(height: 24) - ActivityListView(messages: store.messages) + if store.isCodeSessionBrowserPresented { + CodeSessionsView( + sessions: store.halosSessions, + currentSessionKey: store.currentSessionKey, + switchSession: { key in + store.switchHalosSession(key) + }, + deleteSession: store.deleteHalosSession + ) .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } else { + ActivityListView( + messages: store.messages, + resolveVeyraApproval: store.resolveVeyraApproval + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } + + responseMarker ComposerView( draft: $store.draft, + commands: store.slashCommands, + commandPanelState: store.slashCommandPanelState, + sessions: store.halosSessions, + currentSessionKey: store.currentSessionKey, isSendEnabled: !store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + runCommand: store.runSlashCommand, + runCommandOption: store.submitSlashCommandOption, + dismissCommandPanel: store.dismissSlashCommandPanel, + refreshSessions: store.refreshHalosSessions, + createSession: store.createHalosSession, + switchSession: store.switchHalosSession, attach: openAttachmentPicker, + attachProviders: attachProviders, + attachments: $composerAttachments, + isPageDropTargeted: isChatDropTargeted, + placeholder: composerPlaceholder, send: store.sendDraft ) } + .frame(maxWidth: chatReadableMaxWidth) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } - private func openAttachmentPicker() { + private var composerPlaceholder: String { + if store.isCodeSessionBrowserPresented { + let prompts = [ + "Say anything. I'm here.", + "Start anywhere.", + "Tell me what we're doing.", + "Drop me into the work.", + "What needs attention?", + "Give me the first thread.", + ] + let index = abs(store.currentSessionKey.hashValue) % prompts.count + return prompts[index] + } + return "What are we doing today?" + } + + private func attachProviders(_ providers: [NSItemProvider]) -> Bool { + guard !providers.isEmpty else { return false } + for provider in providers { + ComposerAttachment.load(provider: provider) { url in + Task { @MainActor in + appendAttachment(url) + } + } + } + return true + } + + @MainActor + private func appendAttachment(_ url: URL) { + let item = ComposerAttachmentItem(url: url) + guard !composerAttachments.contains(where: { $0.url == url }) else { return } + composerAttachments.append(item) + } + + private var codeToolbar: some View { + HStack(alignment: .center, spacing: 12) { + CodeSessionBreadcrumb( + isBrowsingSessions: store.isCodeSessionBrowserPresented, + session: store.currentSessionSummary, + headerNamespace: headerNamespace, + showSessions: { + store.isCodeSessionBrowserPresented = true + store.refreshHalosSessions() + } + ) + + Spacer(minLength: 0) + + if !store.isCodeSessionBrowserPresented && openWorkbenchModes.isEmpty { + ViewsMenuButton( + openModes: openWorkbenchModes, + select: { mode in + store.selectedViewMode = mode + toggleWorkbench(mode) + } + ) + } + } + .frame(height: 29) + } + + private var responseMarker: some View { + HStack(spacing: 8) { + HalosResponseMarkView(isActive: store.responseMarkerStatus.isActive && !reduceMotion) + .accessibilityLabel(store.responseMarkerStatus.isActive ? "Huma is responding" : "Huma is idle") + + if store.responseMarkerStatus.isActive { + Text(store.responseMarkerStatus.elapsedText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .monospacedDigit() + + if store.responseMarkerStatus.showsTokens { + Circle() + .fill(HalosTheme.tertiaryText.opacity(0.7)) + .frame(width: 3, height: 3) + + Text(store.responseMarkerStatus.tokenText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .monospacedDigit() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 13) + .padding(.top, 32) + .padding(.bottom, 18) + .animation(.smooth(duration: 0.16), value: store.responseMarkerStatus.isActive) + } + + private func openAttachmentPicker(_ completion: @escaping ([URL]) -> Void) { let panel = NSOpenPanel() panel.title = "Attach context" panel.prompt = "Attach" @@ -800,41 +988,49 @@ private struct CodePageView: View { panel.canCreateDirectories = false panel.begin { response in guard response == .OK, !panel.urls.isEmpty else { return } - let paths = panel.urls.map(\.path).joined(separator: " ") - let prefix = store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : " " - store.draft += "\(prefix)\(paths)" + completion(panel.urls) } } private var workbenchStack: some View { - HSplitView { + HStack(spacing: 8) { ForEach(Array(workbenchColumns.enumerated()), id: \.offset) { columnIndex, column in - WorkbenchColumnView( - modes: column, - columnIndex: columnIndex, - isRightmostColumn: columnIndex == workbenchColumns.count - 1, - selectedViewMode: { mode in workbenchBinding(for: mode) }, - openModes: openWorkbenchModes, - toggleMode: { selectedMode in - store.selectedViewMode = selectedMode - toggleWorkbench(selectedMode) - }, - close: { mode in - closeWorkbench(mode) - }, - startMove: { mode in - draggingWorkbenchMode = mode - }, - moveModeToOwnColumn: { mode, destinationIndex in - moveModeToOwnColumn(mode, destinationIndex: destinationIndex) - }, - draggedMode: $draggingWorkbenchMode, - layout: $workbenchLayout - ) - .frame(minWidth: 248, idealWidth: 316, maxWidth: 560) - } + HStack(spacing: 8) { + WorkbenchColumnView( + modes: column, + columnIndex: columnIndex, + isRightmostColumn: columnIndex == workbenchColumns.count - 1, + selectedViewMode: { mode in workbenchBinding(for: mode) }, + openModes: openWorkbenchModes, + toggleMode: { selectedMode in + store.selectedViewMode = selectedMode + toggleWorkbench(selectedMode) + }, + close: { mode in + closeWorkbench(mode) + }, + startMove: { mode in + draggingWorkbenchMode = mode + }, + moveModeToOwnColumn: { mode, destinationIndex in + moveModeToOwnColumn(mode, destinationIndex: destinationIndex) + }, + draggedMode: $draggingWorkbenchMode, + layout: $workbenchLayout + ) + .frame(width: widthForWorkbenchColumn(at: columnIndex)) + if columnIndex < workbenchColumns.count - 1 { + WorkbenchResizeHandle( + leftWidth: widthBindingForWorkbenchColumn(at: columnIndex), + rightWidth: widthBindingForWorkbenchColumn(at: columnIndex + 1) + ) + .frame(width: 10) + } + } + } } + .fixedSize(horizontal: true, vertical: false) .frame(maxHeight: .infinity, alignment: .top) } @@ -886,6 +1082,7 @@ private struct CodePageView: View { workbenchLayout[lastIndex].append(mode) } else { workbenchLayout.append([mode]) + workbenchColumnWidths.append(workbenchDefaultWidth) } } @@ -902,6 +1099,7 @@ private struct CodePageView: View { normalizeWorkbenchLayout() let index = max(0, min(destinationIndex, workbenchLayout.count)) workbenchLayout.insert([mode], at: index) + workbenchColumnWidths.insert(workbenchDefaultWidth, at: index) normalizeWorkbenchLayout() } } @@ -916,9 +1114,43 @@ private struct CodePageView: View { } private func normalizeWorkbenchLayout() { - workbenchLayout = workbenchLayout - .map { Array($0.prefix(2)) } - .filter { !$0.isEmpty } + workbenchLayout = workbenchLayout.map { Array($0.prefix(2)) } + for index in workbenchLayout.indices.reversed() where workbenchLayout[index].isEmpty { + workbenchLayout.remove(at: index) + if workbenchColumnWidths.indices.contains(index) { + workbenchColumnWidths.remove(at: index) + } + } + syncWorkbenchColumnWidths() + } + + private func syncWorkbenchColumnWidths() { + while workbenchColumnWidths.count < workbenchLayout.count { + workbenchColumnWidths.append(workbenchDefaultWidth) + } + if workbenchColumnWidths.count > workbenchLayout.count { + workbenchColumnWidths.removeLast(workbenchColumnWidths.count - workbenchLayout.count) + } + } + + private func widthForWorkbenchColumn(at index: Int) -> CGFloat { + guard workbenchColumnWidths.indices.contains(index) else { + return workbenchDefaultWidth + } + return workbenchColumnWidths[index] + } + + private func widthBindingForWorkbenchColumn(at index: Int) -> Binding { + Binding( + get: { + widthForWorkbenchColumn(at: index) + }, + set: { newValue in + syncWorkbenchColumnWidths() + guard workbenchColumnWidths.indices.contains(index) else { return } + workbenchColumnWidths[index] = newValue + } + ) } } @@ -1390,6 +1622,8 @@ private struct WorkbenchPlaceholderState: View { private struct AutomationsPageView: View { let automations: [AutomationSummary] @Binding var selectedAutomationID: String? + let isSidebarClosed: Bool + let headerNamespace: Namespace.ID private var selectedAutomation: AutomationSummary? { guard let selectedAutomationID else { return nil } @@ -1420,7 +1654,6 @@ private struct AutomationsPageView: View { .scrollIndicators(.hidden) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 54) .padding(.trailing, 16) if let selectedAutomation { @@ -1442,32 +1675,42 @@ private struct AutomationsPageView: View { private var automationToolbar: some View { HStack(alignment: .center, spacing: 12) { - if let selectedAutomation { - HStack(spacing: 8) { - Button { - selectedAutomationID = nil - } label: { - Text("Automations") - .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(HalosTheme.tertiaryText) - } - .buttonStyle(.plain) - .help("Show all automations") + if !isSidebarClosed { + if let selectedAutomation { + HStack(spacing: 8) { + Button { + selectedAutomationID = nil + } label: { + CollapsingHeaderTitle( + title: "Automations", + id: "automations-title", + namespace: headerNamespace, + isCompact: true, + color: HalosTheme.tertiaryText + ) + } + .buttonStyle(.plain) + .help("Show all automations") - Image(systemName: "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(HalosTheme.tertiaryText) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) - Text(selectedAutomation.name) - .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(HalosTheme.primaryText) - .lineLimit(1) + Text(selectedAutomation.name) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + } + .frame(height: 29) + } else { + CollapsingHeaderTitle( + title: "Automations", + id: "automations-title", + namespace: headerNamespace, + isCompact: false, + color: HalosTheme.primaryText + ) } - .frame(height: 29) - } else { - Text("Automations") - .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(HalosTheme.primaryText) } Spacer(minLength: 0) @@ -1658,76 +1901,962 @@ private struct ViewsMenuButton: View { } } -private struct ComposerView: View { - @Binding var draft: String - let isSendEnabled: Bool - let attach: () -> Void - let send: () -> Void +private struct CodeSessionBreadcrumb: View { + let isBrowsingSessions: Bool + let session: HalosSessionSummary + let headerNamespace: Namespace.ID + let showSessions: () -> Void var body: some View { - HStack(alignment: .center, spacing: 9) { - Button(action: attach) { - Image(systemName: "plus") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(HalosTheme.secondaryText) - .frame(width: 30, height: 30) - .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + HStack(spacing: 8) { + Button(action: showSessions) { + CollapsingHeaderTitle( + title: "Code", + id: "code-title", + namespace: headerNamespace, + isCompact: !isBrowsingSessions, + color: isBrowsingSessions ? HalosTheme.primaryText : HalosTheme.tertiaryText + ) } .buttonStyle(.plain) - .help("Attach context") + .help("Show all sessions") - TextField("What are we doing today?", text: $draft, axis: .vertical) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(HalosTheme.primaryText) - .lineLimit(1...5) - .textFieldStyle(.plain) - .tint(.white) - .frame(minHeight: 30, alignment: .center) - .onSubmit { - send() - } + if !isBrowsingSessions { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) - Button(action: send) { - Image(systemName: "return") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isSendEnabled ? Color.white : HalosTheme.tertiaryText.opacity(0.58)) - .frame(width: 30, height: 30) - .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + Text(sessionTitle) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) } - .buttonStyle(.plain) - .disabled(!isSendEnabled) } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(HalosTheme.separator, lineWidth: 1) + .frame(height: 29) + } + + private var sessionTitle: String { + let preview = session.preview.trimmingCharacters(in: .whitespacesAndNewlines) + return preview.isEmpty ? "New session" : preview + } +} + +private struct CodeSessionsView: View { + let sessions: [HalosSessionSummary] + let currentSessionKey: String + let switchSession: (String) -> Void + let deleteSession: (String) -> Void + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Sessions") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + .padding(.bottom, 3) + + Divider() + .overlay(HalosTheme.faintSeparator) + + if sessions.isEmpty { + EmptyPageState( + symbolName: "chevron.left.forwardslash.chevron.right", + title: "No sessions yet", + message: "Your Halos conversations will appear here." + ) + .padding(.top, 18) + } else { + ForEach(sessions) { session in + CodeSessionRow( + session: session, + isSelected: session.key == currentSessionKey + ) { + switchSession(session.key) + } delete: { + deleteSession(session.key) + } + } + } + } + } + .padding(.vertical, 1) } + .scrollIndicators(.hidden) + .padding(.top, 54) + .padding(.trailing, 16) } } -private struct AutomationListRow: View { - let automation: AutomationSummary +private struct CodeSessionRow: View { + let session: HalosSessionSummary let isSelected: Bool let select: () -> Void + let delete: () -> Void + @State private var isHovering = false var body: some View { - Button(action: select) { - HStack(alignment: .center, spacing: 10) { - HalosGlyph(kind: .automationOrbit) - .foregroundStyle(automation.enabled ? HalosTheme.secondaryText : HalosTheme.tertiaryText) - .opacity(automation.enabled ? 1 : 0.58) - .frame(width: 16, height: 16) - - VStack(alignment: .leading, spacing: 3) { - Text(automation.name) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(HalosTheme.primaryText) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Button(action: select) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected ? HalosTheme.primaryText : HalosTheme.tertiaryText) + .frame(width: 18, height: 18) - HStack(spacing: 6) { - Text(automation.agentId) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + + Text(session.updatedAtText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.leading, 10) + .padding(.trailing, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: delete) { + Image(systemName: "trash") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + .frame(width: 30, height: 30) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .opacity((isHovering || isSelected) ? 1 : 0) + .help("Delete session") + } + .background(isSelected ? HalosTheme.navActiveFill : Color.clear, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .onHover { isHovering = $0 } + .help(title) + } + + private var title: String { + let preview = session.preview.trimmingCharacters(in: .whitespacesAndNewlines) + return preview.isEmpty ? "New session" : preview + } +} + +private struct ComposerView: View { + @Binding var draft: String + let commands: [SlashCommand] + let commandPanelState: SlashCommandPanelState? + let sessions: [HalosSessionSummary] + let currentSessionKey: String + let isSendEnabled: Bool + let runCommand: (SlashCommand) -> Void + let runCommandOption: (String) -> Void + let dismissCommandPanel: () -> Void + let refreshSessions: () -> Void + let createSession: () -> Void + let switchSession: (String) -> Void + let attach: (@escaping ([URL]) -> Void) -> Void + let attachProviders: ([NSItemProvider]) -> Bool + @Binding var attachments: [ComposerAttachmentItem] + let isPageDropTargeted: Bool + let placeholder: String + let send: () -> Void + @State private var didRequestSessionRefresh = false + @State private var fileResults: [ComposerFileResult] = [] + @State private var isDropTargeted = false + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + if shouldShowSessionsPanel { + HalosSessionsCommandView( + sessions: sessions, + currentSessionKey: currentSessionKey, + createSession: { + createSession() + draft = "" + }, + switchSession: { key in + switchSession(key) + draft = "" + } + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } else if let commandPanelState { + SlashCommandPanelView( + state: commandPanelState, + selectOption: selectCommandOption, + dismiss: dismissCommandPanel + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } else if shouldShowFileSearch { + FileMentionCompletionView(results: fileResults, select: selectFile) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } else if shouldShowCommands { + SlashCommandCompletionView(commands: filteredCommands, select: selectCommand) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 7) { + if !attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(attachments) { attachment in + ComposerAttachmentChip( + attachment: attachment, + remove: { + removeAttachment(attachment) + } + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(alignment: .center, spacing: 9) { + Button(action: openAttachmentPicker) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + .frame(width: 30, height: 30) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .help("Attach context") + + TextField(placeholder, text: $draft, axis: .vertical) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1...5) + .textFieldStyle(.plain) + .tint(.white) + .frame(minHeight: 30, alignment: .center) + .onSubmit { + submitComposer() + } + .onDrop( + of: ComposerAttachment.acceptedTypeIdentifiers, + isTargeted: $isDropTargeted, + perform: attachProviders + ) + + Button(action: submitComposer) { + Image(systemName: "return") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(canSend ? Color.white : HalosTheme.tertiaryText.opacity(0.58)) + .frame(width: 30, height: 30) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!canSend) + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + .padding(.top, attachments.isEmpty ? 6 : 0) + } + .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke((isDropTargeted || isPageDropTargeted) ? HalosTheme.primaryText.opacity(0.55) : HalosTheme.separator, lineWidth: 1) + } + .onDrop( + of: ComposerAttachment.acceptedTypeIdentifiers, + isTargeted: $isDropTargeted, + perform: attachProviders + ) + .onPasteCommand(of: ComposerAttachment.acceptedTypes) { providers in + _ = attachProviders(providers) + } + } + } + .animation(.smooth(duration: 0.16), value: shouldShowCommands) + .animation(.smooth(duration: 0.16), value: shouldShowFileSearch) + .animation(.smooth(duration: 0.16), value: shouldShowSessionsPanel) + .animation(.smooth(duration: 0.16), value: commandPanelState) + .animation(.smooth(duration: 0.20), value: attachments) + .task(id: fileMentionQuery) { + await refreshFileResults() + } + .onChange(of: shouldShowSessionsPanel) { _, isVisible in + if isVisible, !didRequestSessionRefresh { + didRequestSessionRefresh = true + refreshSessions() + } else if !isVisible { + didRequestSessionRefresh = false + } + } + } + + private var shouldShowCommands: Bool { + !shouldShowSessionsPanel && !shouldShowFileSearch && !filteredCommands.isEmpty + } + + private var shouldShowFileSearch: Bool { + fileMentionQuery != nil && !fileResults.isEmpty + } + + private var shouldShowSessionsPanel: Bool { + false + } + + private var canSend: Bool { + isSendEnabled || !attachments.isEmpty + } + + private var filteredCommands: [SlashCommand] { + guard let query = slashQuery else { return [] } + guard !commands.isEmpty else { return [] } + if query.isEmpty { + return Array(commands.prefix(7)) + } + return commands + .filter { command in + command.command.localizedCaseInsensitiveContains(query) + || command.description.localizedCaseInsensitiveContains(query) + } + .prefix(7) + .map { $0 } + } + + private var slashQuery: String? { + guard draft.hasPrefix("/") else { return nil } + let remainder = String(draft.dropFirst()) + guard !remainder.contains(where: \.isWhitespace) else { return nil } + return remainder + } + + private var fileMentionQuery: String? { + guard !draft.hasPrefix("/") else { return nil } + guard let range = draft.range(of: "@", options: .backwards) else { return nil } + let suffix = draft[range.upperBound...] + guard !suffix.contains(where: \.isWhitespace) else { return nil } + return String(suffix) + } + + private func selectCommand(_ command: SlashCommand) { + runCommand(command) + } + + private func selectCommandOption(_ option: String) { + draft = "" + runCommandOption(option) + } + + private func selectFile(_ result: ComposerFileResult) { + guard let range = draft.range(of: "@", options: .backwards) else { + appendAttachmentReference(result.url) + return + } + let replacement = ComposerAttachment.format(url: result.url) + " " + draft.replaceSubrange(range.lowerBound..., with: replacement) + fileResults = [] + } + + private func refreshFileResults() async { + guard let query = fileMentionQuery else { + await MainActor.run { + fileResults = [] + } + return + } + let results = await ComposerFileSearch.search(query: query) + guard !Task.isCancelled else { return } + await MainActor.run { + fileResults = results + } + } + + @MainActor + private func appendAttachment(_ url: URL) { + let item = ComposerAttachmentItem(url: url) + guard !attachments.contains(where: { $0.url == url }) else { return } + attachments.append(item) + } + + private func openAttachmentPicker() { + attach { urls in + Task { @MainActor in + for url in urls { + appendAttachment(url) + } + } + } + } + + private func removeAttachment(_ attachment: ComposerAttachmentItem) { + attachments.removeAll { $0.id == attachment.id } + } + + @MainActor + private func appendAttachmentReference(_ url: URL) { + let reference = ComposerAttachment.format(url: url) + let needsSpace = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !draft.hasSuffix(" ") + && !draft.hasSuffix("\n") + draft += needsSpace ? " \(reference)" : reference + } + + private func submitComposer() { + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !attachments.isEmpty else { return } + if commandPanelState != nil, !trimmed.hasPrefix("/") { + runCommandOption(trimmed) + return + } + appendAttachmentReferencesForSend() + send() + } + + private func appendAttachmentReferencesForSend() { + guard !attachments.isEmpty else { return } + let references = attachments.map { ComposerAttachment.format(url: $0.url) }.joined(separator: " ") + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + draft = trimmed.isEmpty ? references : "\(trimmed)\n\(references)" + attachments.removeAll() + } +} + +private struct ComposerAttachmentItem: Identifiable, Equatable { + let id = UUID() + let url: URL + + var title: String { + url.lastPathComponent.isEmpty ? url.path : url.lastPathComponent + } + + var iconName: String { + let contentType = (try? url.resourceValues(forKeys: [.contentTypeKey]).contentType) + if contentType?.conforms(to: .image) == true { return "photo" } + if contentType?.conforms(to: .movie) == true { return "film" } + if contentType?.conforms(to: .audio) == true { return "waveform" } + if contentType?.conforms(to: .html) == true { return "chevron.left.forwardslash.chevron.right" } + if contentType?.conforms(to: .pdf) == true { return "doc.richtext" } + if contentType?.conforms(to: .folder) == true { return "folder" } + return "doc" + } +} + +private struct ComposerAttachmentChip: View { + let attachment: ComposerAttachmentItem + let remove: () -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: attachment.iconName) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + + Text(attachment.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(HalosTheme.tertiaryText) + .frame(width: 18, height: 18) + .contentShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + .buttonStyle(.plain) + .help("Remove attachment") + } + .padding(.leading, 9) + .padding(.trailing, 5) + .frame(height: 28) + .frame(maxWidth: 220) + .background(HalosTheme.rowFill.opacity(0.95), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(HalosTheme.separator.opacity(0.95), lineWidth: 1) + } + } +} + +private struct ComposerFileResult: Identifiable, Equatable, Sendable { + let url: URL + let title: String + let subtitle: String + + var id: String { url.path } +} + +private enum ComposerFileSearch { + static func search(query: String) async -> [ComposerFileResult] { + await Task.detached(priority: .userInitiated) { + performSearch(query: query) + }.value + } + + private static func performSearch(query: String) -> [ComposerFileResult] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let roots = searchRoots() + let fileManager = FileManager.default + var results: [ScoredResult] = [] + var visited = 0 + + for root in roots where fileManager.fileExists(atPath: root.path) { + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + continue + } + + for case let url as URL in enumerator { + visited += 1 + if visited > 8_000 { break } + + let name = url.lastPathComponent + if shouldSkip(name: name) { + enumerator.skipDescendants() + continue + } + + let values = try? url.resourceValues(forKeys: [.isDirectoryKey]) + if values?.isDirectory == true { + continue + } + + let relativePath = relativePath(for: url, root: root) + let haystack = "\(name) \(relativePath)".lowercased() + guard normalizedQuery.isEmpty || haystack.contains(normalizedQuery) else { continue } + + results.append(ScoredResult( + result: ComposerFileResult(url: url, title: name, subtitle: relativePath), + score: score(name: name, relativePath: relativePath, query: normalizedQuery) + )) + if results.count > 80 { + results.sort { $0.score < $1.score } + results = Array(results.prefix(24)) + } + } + } + + return results + .sorted { lhs, rhs in + lhs.score == rhs.score + ? lhs.result.subtitle.localizedCaseInsensitiveCompare(rhs.result.subtitle) == .orderedAscending + : lhs.score < rhs.score + } + .prefix(8) + .map(\.result) + } + + private struct ScoredResult { + let result: ComposerFileResult + let score: Int + } + + private static func searchRoots() -> [URL] { + var roots: [URL] = [] + if let envRoot = ProcessInfo.processInfo.environment["HALOS_WORKSPACE"], !envRoot.isEmpty { + roots.append(URL(fileURLWithPath: envRoot)) + } + roots.append(URL(fileURLWithPath: "/Volumes/Thorium/Projects/Halos/codex")) + roots.append(URL(fileURLWithPath: "/Volumes/Thorium/Projects")) + roots.append(FileManager.default.homeDirectoryForCurrentUser.appending(path: "Documents")) + + var seen: Set = [] + return roots.filter { root in + let standardized = root.standardizedFileURL.path + guard !seen.contains(standardized) else { return false } + seen.insert(standardized) + return true + } + } + + private static func shouldSkip(name: String) -> Bool { + let skipped = [ + ".build", + ".git", + ".swiftpm", + "DerivedData", + "node_modules", + "dist", + "build", + ".DS_Store", + ] + return skipped.contains(name) + } + + private static func relativePath(for url: URL, root: URL) -> String { + let rootPath = root.standardizedFileURL.path + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath) else { return path } + return String(path.dropFirst(rootPath.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private static func score(name: String, relativePath: String, query: String) -> Int { + guard !query.isEmpty else { return relativePath.split(separator: "/").count } + let lowerName = name.lowercased() + let lowerPath = relativePath.lowercased() + if lowerName == query { return 0 } + if lowerName.hasPrefix(query) { return 1 } + if lowerName.contains(query) { return 2 } + if lowerPath.hasPrefix(query) { return 3 } + return 4 + lowerPath.split(separator: "/").count + } +} + +private enum ComposerAttachment { + static let acceptedTypes: [UTType] = [ + .fileURL, + .png, + .jpeg, + .tiff, + .gif, + .heic, + .image, + .pdf, + .html, + .rtf, + .rtfd + ] + static let acceptedTypeIdentifiers = acceptedTypes + + static func format(url: URL) -> String { + let path = url.path + guard path.contains(where: \.isWhitespace) else { + return "@\(path)" + } + let escaped = path.replacingOccurrences(of: "\"", with: "\\\"") + return "@\"\(escaped)\"" + } + + static func load(provider: NSItemProvider, completion: @escaping @Sendable (URL) -> Void) { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + if let url = item as? URL { + completion(url) + return + } + if let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) { + completion(url) + } + } + return + } + + for type in dataTypes where provider.hasItemConformingToTypeIdentifier(type.identifier) { + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in + guard let data, let url = save(data: data, contentType: type) else { return } + completion(url) + } + return + } + } + + private static var dataTypes: [UTType] { + [ + .png, + .jpeg, + .tiff, + .gif, + .heic, + .image, + .pdf, + .html, + .rtf, + .rtfd + ] + } + + private static func save(data: Data, contentType: UTType) -> URL? { + let directory = HalosStorage.attachmentsURL + do { + try HalosStorage.ensureLayout() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileExtension = contentType.preferredFilenameExtension ?? "dat" + let name = "attachment-\(Int(Date().timeIntervalSince1970 * 1000))-\(UUID().uuidString.prefix(8)).\(fileExtension)" + let url = directory.appending(path: name) + try data.write(to: url, options: .atomic) + return url + } catch { + return nil + } + } +} + +private struct SlashCommandPanelView: View { + let state: SlashCommandPanelState + let selectOption: (String) -> Void + let dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 10) { + Text(state.command.displayCommand) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .padding(.horizontal, 9) + .frame(height: 26) + .background(HalosTheme.rowFill, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + + Text(state.isRunning ? "Running" : "Done") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(state.isRunning ? HalosTheme.secondaryText : HalosTheme.tertiaryText) + + Spacer(minLength: 0) + + Button(action: dismiss) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(HalosTheme.tertiaryText) + .frame(width: 24, height: 24) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + } + + if let error = state.error, !error.isEmpty { + Text(error) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.red.opacity(0.86)) + .fixedSize(horizontal: false, vertical: true) + } else if let output = state.output, !output.isEmpty { + Text(output) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(HalosTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + + if !state.options.isEmpty { + HStack(spacing: 12) { + ForEach(state.options, id: \.self) { option in + Button { + selectOption(option) + } label: { + Text(option) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 1) + } + } else { + Text(state.command.description) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .lineLimit(2) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HalosTheme.separator, lineWidth: 1) + } + } +} + +private struct HalosSessionsCommandView: View { + let sessions: [HalosSessionSummary] + let currentSessionKey: String + let createSession: () -> Void + let switchSession: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button(action: createSession) { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(HalosTheme.primaryText) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 2) { + Text("New Halos session") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + + Text("Start a fresh Huma thread") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: 38) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + + if sessions.isEmpty { + Text("No previous Halos sessions yet.") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } else { + ForEach(sessions.prefix(6)) { session in + Button { + switchSession(session.key) + } label: { + HStack(spacing: 10) { + Image(systemName: session.key == currentSessionKey ? "checkmark.circle.fill" : "circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(session.key == currentSessionKey ? HalosTheme.primaryText : HalosTheme.tertiaryText) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(session.label) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + + Text("\(session.status) · \(session.updatedAtText)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: 38) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + } + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HalosTheme.separator, lineWidth: 1) + } + } +} + +private struct SlashCommandCompletionView: View { + let commands: [SlashCommand] + let select: (SlashCommand) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(commands) { command in + Button { + select(command) + } label: { + HStack(spacing: 10) { + Text(command.displayCommand) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .frame(minWidth: 82, alignment: .leading) + + Text(command.description) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(HalosTheme.secondaryText) + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: 30) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HalosTheme.separator, lineWidth: 1) + } + } +} + +private struct FileMentionCompletionView: View { + let results: [ComposerFileResult] + let select: (ComposerFileResult) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(results) { result in + Button { + select(result) + } label: { + HStack(spacing: 10) { + Image(systemName: "doc.text") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.secondaryText) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + + Text(result.subtitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(HalosTheme.tertiaryText) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: 38) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HalosTheme.sidebarFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HalosTheme.separator, lineWidth: 1) + } + } +} + +private struct AutomationListRow: View { + let automation: AutomationSummary + let isSelected: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + HStack(alignment: .center, spacing: 10) { + HalosGlyph(kind: .automationOrbit) + .foregroundStyle(automation.enabled ? HalosTheme.secondaryText : HalosTheme.tertiaryText) + .opacity(automation.enabled ? 1 : 0.58) + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 3) { + Text(automation.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(HalosTheme.primaryText) + .lineLimit(1) + + HStack(spacing: 6) { + Text(automation.agentId) .lineLimit(1) Text("·") @@ -1797,7 +2926,7 @@ private struct AutomationInspectorView: View { inspectorSection("Schedule") { InspectorField(label: "Type", value: automation.scheduleKind) InspectorField(label: "When", value: automation.detail) - InspectorField(label: "Timezone", value: automation.timezone ?? "System") + InspectorField(label: "Timezone", value: timezoneText) InspectorField(label: "One time", value: automation.deleteAfterRun ? "Yes" : "No") } @@ -1839,6 +2968,23 @@ private struct AutomationInspectorView: View { return createdAt.formatted(date: .abbreviated, time: .shortened) } + private var timezoneText: String { + switch automation.timezone { + case "America/Chicago": + return "Central Time" + case "America/New_York": + return "Eastern Time" + case "America/Denver": + return "Mountain Time" + case "America/Los_Angeles": + return "Pacific Time" + case let timezone?: + return timezone.replacingOccurrences(of: "_", with: " ") + case nil: + return "System" + } + } + private func inspectorSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 9) { Text(title) @@ -1929,8 +3075,6 @@ private struct EmptyStateIcon: View { switch symbolName { case "halos.automation": HalosGlyph(kind: .automationOrbit) - case "halos.settings": - HalosGlyph(kind: .settingsDots) default: Image(systemName: symbolName) .font(.system(size: 22, weight: .medium)) diff --git a/Sources/Halos/Views/HalosGlyph.swift b/Sources/Halos/Views/HalosGlyph.swift new file mode 100644 index 0000000..07a6c2e --- /dev/null +++ b/Sources/Halos/Views/HalosGlyph.swift @@ -0,0 +1,85 @@ +import SwiftUI + +enum HalosDotMarkGeometry { + private static let appIconStep: CGFloat = 220 + private static let appIconRadius: CGFloat = 82 + private static let appIconMarkExtent: CGFloat = appIconStep * 2 + appIconRadius * 2 + + static func radius(in size: CGFloat) -> CGFloat { + size * (appIconRadius / appIconMarkExtent) + } + + static func centers(in size: CGFloat) -> [CGPoint] { + let step = size * (appIconStep / appIconMarkExtent) + return [ + CGPoint(x: -step / 2, y: -step), + CGPoint(x: step / 2, y: -step), + CGPoint(x: -step, y: 0), + CGPoint(x: 0, y: 0), + CGPoint(x: step, y: 0), + CGPoint(x: -step / 2, y: step), + CGPoint(x: step / 2, y: step), + ] + } +} + +enum HalosGlyphKind { + case settingsDots + case automationOrbit +} + +struct HalosGlyph: View { + let kind: HalosGlyphKind + + var body: some View { + GeometryReader { proxy in + let size = min(proxy.size.width, proxy.size.height) + ZStack { + switch kind { + case .settingsDots: + settingsDots(size: size, radiusScale: 1.0) + case .automationOrbit: + automationOrbit(size: size) + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .aspectRatio(1, contentMode: .fit) + } + + private func settingsDots(size: CGFloat, radiusScale: CGFloat) -> some View { + let r = HalosDotMarkGeometry.radius(in: size) * radiusScale + let dots = HalosDotMarkGeometry.centers(in: size) + + return ZStack { + ForEach(Array(dots.enumerated()), id: \.offset) { _, point in + Circle() + .frame(width: r * 2, height: r * 2) + .shadow(color: .white.opacity(0.14), radius: size * 0.028) + .offset(x: point.x, y: point.y) + } + } + } + + private func automationOrbit(size: CGFloat) -> some View { + let r = size * 0.115 + return ZStack { + Circle() + .stroke(lineWidth: max(1, size * 0.075)) + .opacity(0.46) + .frame(width: size * 0.68, height: size * 0.68) + + Circle() + .frame(width: r * 2, height: r * 2) + .offset(x: -size * 0.22, y: -size * 0.22) + + Circle() + .frame(width: r * 2, height: r * 2) + .offset(x: size * 0.26, y: -size * 0.04) + + Circle() + .frame(width: r * 2, height: r * 2) + .offset(x: -size * 0.08, y: size * 0.26) + } + } +} diff --git a/Sources/HalosApp/MissionControlController.swift b/Sources/HalosApp/MissionControlController.swift index 3389ff0..bf6b153 100644 --- a/Sources/HalosApp/MissionControlController.swift +++ b/Sources/HalosApp/MissionControlController.swift @@ -16,7 +16,11 @@ final class MissionControlController: NSObject { func showMainWindow() { if mainWindow == nil { - let controller = NSHostingController(rootView: HalosControlWindowView(store: store)) + let controller = NSHostingController( + rootView: HalosControlWindowView(store: store) + .tint(.white) + .accentColor(.white) + ) let window = NSWindow(contentViewController: controller) window.title = "" window.setContentSize(NSSize(width: 820, height: 620)) diff --git a/Tests/HalosUITests/GatewayStreamingTests.swift b/Tests/HalosUITests/GatewayStreamingTests.swift index 119e3dd..f9cca23 100644 --- a/Tests/HalosUITests/GatewayStreamingTests.swift +++ b/Tests/HalosUITests/GatewayStreamingTests.swift @@ -36,30 +36,293 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(store.messages.first(where: { $0.kind == .assistant })?.body, "Hello world") } - func testToolStartUpdateResultUpdatesOneToolRow() { + func testToolStartUpdateResultUpdatesOneWorkSummary() { let store = makeStore() store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "Checking")) - store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-1", name: "read")) - store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "update", toolCallID: "tool-1", name: "read")) - store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-1", name: "read")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-1", name: "read", data: ["path": "/Users/huntmarketing/.openclaw"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "update", toolCallID: "tool-1", name: "read", data: ["path": "/Users/huntmarketing/.openclaw"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-1", name: "read", data: ["path": "/Users/huntmarketing/.openclaw"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-2", name: "read", data: ["path": "/Users/huntmarketing/.openclaw"])) - let toolMessages = store.messages.filter { $0.toolCallId == "tool-1" } - XCTAssertEqual(toolMessages.count, 1) - XCTAssertEqual(toolMessages.first?.title, "Files") - XCTAssertEqual(toolMessages.first?.body, "Files completed") - XCTAssertEqual(toolMessages.first?.streamState, .final) + let workSummaries = store.messages.filter { $0.kind == .workSummary } + XCTAssertEqual(workSummaries.count, 1) + XCTAssertEqual(workSummaries.first?.title, "Explored 2 Files") + XCTAssertEqual(store.messages.filter { $0.kind == .tool }.count, 0) + XCTAssertEqual(workSummaries.first?.body.contains("Read /Users/huntmarketing/.openclaw x2"), true) + XCTAssertEqual(workSummaries.first?.body.contains("Tools:"), false) + XCTAssertEqual(workSummaries.first?.body.contains("Latest:"), false) + } + + func testToolSummaryUsesHumanActionCopy() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "Working")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-terminal", name: "terminal", data: ["command": "swift test"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-edit", name: "apply_patch", data: ["path": "Sources/Halos/Stores/MissionControlStore.swift"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-search", name: "search_files", data: ["query": "MissionControlStore"])) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Edited File, Ran Command, Explored File") + XCTAssertEqual(summary?.body.contains("Ran swift test"), true) + XCTAssertEqual(summary?.body.contains("Edited Sources/Halos/Stores/MissionControlStore.swift"), true) + XCTAssertEqual(summary?.body.contains("Searched MissionControlStore"), true) + XCTAssertEqual(summary?.body.contains("Tools:"), false) + XCTAssertEqual(summary?.body.contains("Latest:"), false) + } + + func testToolSummaryExtractsJsonEncodedArguments() { + let store = makeStore() + + store.ingestGatewayEventForTesting( + event: "agent", + payload: toolPayload( + runID: "run-1", + phase: "result", + toolCallID: "tool-command", + name: "exec_command", + data: ["arguments": #"{"cmd":"ls /Users/huntmarketing/.openclaw"}"#] + ) + ) + store.ingestGatewayEventForTesting( + event: "agent", + payload: toolPayload( + runID: "run-1", + phase: "result", + toolCallID: "tool-read", + name: "read", + data: ["inputJson": #"{"filePath":"/Users/huntmarketing/.openclaw/AGENTS.md"}"#] + ) + ) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.body.contains("Ran ls /Users/huntmarketing/.openclaw"), true) + XCTAssertEqual(summary?.body.contains("Read /Users/huntmarketing/.openclaw/AGENTS.md"), true) + XCTAssertEqual(summary?.body.contains("Ran command"), false) + XCTAssertEqual(summary?.body.contains("Read file"), false) + } + + func testProcessToolSummaryUsesSpecificLabel() { + let store = makeStore() + + store.ingestGatewayEventForTesting( + event: "agent", + payload: toolPayload( + runID: "run-1", + phase: "result", + toolCallID: "tool-process", + name: "Process", + data: ["processName": "openclaw-gateway"] + ) + ) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.body.contains("Checked openclaw-gateway"), true) + XCTAssertEqual(summary?.body.contains("Used Process"), false) + } + + func testFinalAssistantMessageDoesNotCreateEmptyWorkSummary() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I can run that now.")) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: "Done. Thorium is healthy.")) + + XCTAssertTrue(store.messages.filter { $0.kind == .workSummary }.isEmpty) + XCTAssertFalse(store.messages.contains { $0.body == "No tools used yet." }) + } + + func testWorkSummaryFinalizesWhenRunEnds() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-1", name: "exec_command", data: ["command": "swift test"])) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: "Done.")) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.streamState, .final) + XCTAssertEqual(summary?.body.contains("Ran swift test"), true) + } + + func testGatewayDisconnectStopsActiveRunAndFinalizesWorkSummary() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-1", name: "exec_command", data: ["command": "swift test"])) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-2", name: "terminal", data: ["command": "tail -f gateway.log"])) + + var summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.body.contains("Active: Terminal"), true) + XCTAssertEqual(store.responseMarkerStatus.isActive, true) + + store.handleGatewayDisconnectForTesting() + + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.streamState, .final) + XCTAssertEqual(summary?.body.contains("Ran swift test"), true) + XCTAssertEqual(summary?.body.contains("Active: Terminal"), false) + XCTAssertEqual(store.responseMarkerStatus.isActive, false) + } + + func testGatewayDisconnectRemovesEmptyActiveWorkSummary() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-1", name: "terminal", data: ["command": "tail -f gateway.log"])) + XCTAssertNotNil(store.messages.first(where: { $0.kind == .workSummary })) + + store.handleGatewayDisconnectForTesting() + + XCTAssertNil(store.messages.first(where: { $0.kind == .workSummary })) + XCTAssertEqual(store.responseMarkerStatus.isActive, false) + } + + func testToolSummarySplitsAfterAssistantTextResumes() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll make the repo skeleton.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-1", name: "exec_command", data: ["args": ["command": "mkdir Veyra"]])) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll make the repo skeleton. Done.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-2", name: "read", data: ["input": ["path": "/Users/huntmarketing/.openclaw/workspace/Veyra/README.md"]])) + + let workSummaries = store.messages.filter { $0.kind == .workSummary } + XCTAssertEqual(workSummaries.count, 2) + XCTAssertEqual(workSummaries.first?.body.contains("Ran mkdir Veyra"), true) + XCTAssertEqual(workSummaries.last?.body.contains("Read /Users/huntmarketing/.openclaw/workspace/Veyra/RE..."), true) + XCTAssertEqual(workSummaries.last?.body.contains("Ran mkdir Veyra"), false) + } + + func testToolSummaryShowsAllDistinctTools() { + let store = makeStore() + + for index in 0..<10 { + store.ingestGatewayEventForTesting( + event: "agent", + payload: toolPayload( + runID: "run-1", + phase: "result", + toolCallID: "tool-\(index)", + name: "read", + data: ["path": "/tmp/file-\(index).swift"] + ) + ) + } + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + for index in 0..<10 { + XCTAssertEqual(summary?.body.contains("Read /tmp/file-\(index).swift"), true) + } + } + + func testThinkingStatusDisappearsWhenWorkStarts() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: lifecyclePayload(runID: "run-1", phase: "start")) + XCTAssertEqual(store.messages.first?.kind, .system) + let thinkingTitle = store.messages.first?.title + XCTAssertEqual(thinkingTitle?.isEmpty, false) + XCTAssertNotEqual(thinkingTitle, "Worked") + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "Working now")) + + XCTAssertNil(store.messages.first(where: { $0.title == thinkingTitle })) + XCTAssertNotNil(store.messages.first(where: { $0.kind == .assistant })) + } + + func testResponseMarkerStatusActivatesAndShowsTokens() { + let store = makeStore() + + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "run-1", state: "delta", text: "Working now", totalTokens: 1234) + ) + + XCTAssertEqual(store.responseMarkerStatus.isActive, true) + XCTAssertEqual(store.responseMarkerStatus.elapsedText.hasSuffix("s"), true) + XCTAssertEqual(store.responseMarkerStatus.tokenText, "1.2k tokens") + XCTAssertEqual(store.responseMarkerStatus.showsTokens, true) + + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "run-1", state: "final", text: "Working now", totalTokens: 1240) + ) + + XCTAssertEqual(store.responseMarkerStatus.isActive, false) + XCTAssertEqual(store.responseMarkerStatus.tokenText, "1.2k tokens") + } + + func testResponseMarkerShowsEstimatedTokensBeforeUsageArrives() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "This is a longer streaming response.")) + + XCTAssertEqual(store.responseMarkerStatus.isActive, true) + XCTAssertEqual(store.responseMarkerStatus.tokenText.hasPrefix("~"), true) + XCTAssertEqual(store.responseMarkerStatus.showsTokens, true) + } + + func testResponseMarkerHidesTokensWhileThinking() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: lifecyclePayload(runID: "run-1", phase: "start")) + + XCTAssertEqual(store.responseMarkerStatus.isActive, true) + XCTAssertEqual(store.responseMarkerStatus.phaseText.isEmpty, false) + XCTAssertEqual(store.responseMarkerStatus.showsTokens, false) + } + + func testThinkingWordStaysStableDuringOneRun() async throws { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: lifecyclePayload(runID: "run-1", phase: "start")) + let title = store.messages.first?.title + let markerPhase = store.responseMarkerStatus.phaseText + + try await Task.sleep(for: .milliseconds(2200)) + + XCTAssertEqual(store.messages.first?.title, title) + XCTAssertEqual(store.responseMarkerStatus.phaseText, markerPhase) } func testLazuliAndEdorasLabelsRenderCleanly() { - XCTAssertEqual(GatewayStreamFormatter.toolDisplayName("edoras_safari__open_url"), "Edoras Safari") - XCTAssertEqual(GatewayStreamFormatter.toolDisplayName("lazuli_click"), "Lazuli") + XCTAssertEqual(GatewayStreamFormatter.toolDisplayName("edoras_safari__open_url"), "Browser Control") + XCTAssertEqual(GatewayStreamFormatter.toolDisplayName("lazuli_click"), "Computer Control") + XCTAssertEqual(GatewayStreamFormatter.toolDisplay("edoras_safari__open_url").category, .plugin) + XCTAssertEqual(GatewayStreamFormatter.toolDisplay("read").category, .tool) let store = makeStore() store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "Taking control")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-edoras", name: "edoras_safari__open_url")) + var summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Using Browser Control") + XCTAssertEqual(summary?.body.contains("Opening"), true) + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-edoras", name: "edoras_safari__open_url")) + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Browser Control ended") + XCTAssertEqual(summary?.body.contains("Opened"), true) + store.ingestLazuliEventForTesting(jsonString: #"{"type":"action","tool":"click","args":{"x":1},"expected_layer":2,"session_id":"abcdef123456","at":1777050000000}"#) - XCTAssertEqual(store.messages.first(where: { $0.title == "Lazuli" })?.kind, .tool) + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Computer Control ended") + XCTAssertEqual(summary?.body.contains("Click"), true) + XCTAssertEqual(summary?.body.contains("Plugins:"), false) + XCTAssertEqual(summary?.body.contains("Browser Control completed"), false) + } + + func testVeyraApprovalEventsRenderAndUpdateOneCard() { + let store = makeStore() + + store.ingestVeyraEventForTesting(jsonString: veyraApprovalEvent(status: "pending")) + + XCTAssertEqual(store.messages.count, 1) + XCTAssertEqual(store.messages.first?.id, "veyra-approval_123") + XCTAssertEqual(store.messages.first?.title, "Veyra approval needed") + XCTAssertEqual(store.messages.first?.veyraApproval?.approvalId, "approval_123") + XCTAssertEqual(store.messages.first?.veyraApproval?.body, "Draft the announcement.") + + store.ingestVeyraEventForTesting(jsonString: veyraApprovalEvent(status: "approved")) + + XCTAssertEqual(store.messages.count, 1) + XCTAssertEqual(store.messages.first?.title, "Veyra approval resolved") + XCTAssertEqual(store.messages.first?.veyraApproval?.status, "approved") } func testOutOfSessionEventsAreIgnored() { @@ -70,36 +333,437 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertTrue(store.messages.isEmpty) } + func testRuntimeStartupDumpIsHiddenFromTranscript() { + let store = makeStore() + let startupDump = """ + [Startup context loaded by runtime] + Bootstrap files like SOUL.md, USER.md, and MEMORY.md are already provided separately when eligible. + [Untrusted daily memory: memory/2026-04-24.md] + BEGIN_QUOTED_NOTES + internal notes + END_QUOTED_NOTES + A new session was started via /new or /reset. Execute your Session Startup sequence now. + """ + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: startupDump, role: "user")) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-2", state: "final", text: "Visible answer", role: "assistant")) + + XCTAssertFalse(store.messages.contains { $0.body.contains("Startup context loaded by runtime") }) + XCTAssertEqual(store.messages.filter { $0.kind == .assistant }.map(\.body), ["Visible answer"]) + } + + func testAutomatedRuntimeMessageIsSystemNotice() { + let store = makeStore() + let automatedMessage = """ + System (untrusted): [2026-04-24 23:02:42 CDT] Exec completed (amber-ca, code 0) :: 18k/200k (9%) · 83% cached | + |-----------------------------| + An async command you ran earlier has completed. The result is shown in the system messages above. Handle the result internally. + Current time: Friday, April 24th, 2026 - 11:03 PM + """ + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: automatedMessage, role: "user")) + + XCTAssertEqual(store.messages.first?.kind, .system) + XCTAssertEqual(store.messages.first?.title, "System notice") + XCTAssertEqual(store.messages.first?.body.hasPrefix("An async command"), true) + } + + func testPlainSystemRuntimeMessageIsSystemNotice() { + let store = makeStore() + let systemMessage = """ + System: [2026-04-25 13:01:19 CDT] Gateway restart restart ok (gateway.restart) + System: Restarting OpenClaw to apply latency tuning. + System: Run: openclaw doctor --non-interactive + + Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. + Current time: Saturday, April 25th, 2026 - 1:01 PM + """ + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: systemMessage, role: "user")) + + XCTAssertEqual(store.messages.first?.kind, .system) + XCTAssertEqual(store.messages.first?.title, "System notice") + XCTAssertEqual(store.messages.first?.body.hasPrefix("Read HEARTBEAT.md"), true) + } + + func testSlashCommandsLoadFromTelegramCustomCommandsOnly() throws { + let configURL = FileManager.default.temporaryDirectory + .appending(path: "halos-openclaw-\(UUID().uuidString).json") + let config = """ + { + "gateway": { "port": 18789 }, + "channels": { + "telegram": { + "commands": { "nativeSkills": false, "native": true }, + "customCommands": [ + { "command": "status", "description": "Show system and channel health" }, + { "command": "/compact", "description": "Compact the current context" } + ] + } + } + } + """ + try config.write(to: configURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: configURL) } + + let store = MissionControlStore( + openClawConfigURL: configURL, + cronJobsURL: URL(fileURLWithPath: "/tmp/missing-cron.json"), + lazuliPortFileURL: URL(fileURLWithPath: "/tmp/missing-lazuli-port") + ) + + XCTAssertEqual( + store.slashCommands.map(\.displayCommand), + ["/status", "/compact", "/new", "/files", "/clear", "/plugins", "/automations", "/stop"] + ) + XCTAssertEqual(store.slashCommands.first?.description, "Show system and channel health") + XCTAssertEqual(store.slashCommands.last?.description, "Stop the current run") + } + + func testAutomationSchedulesRenderHumanReadableCopy() throws { + let cronURL = FileManager.default.temporaryDirectory + .appending(path: "halos-cron-\(UUID().uuidString).json") + let configURL = FileManager.default.temporaryDirectory + .appending(path: "halos-openclaw-\(UUID().uuidString).json") + let jobs = """ + { + "version": 1, + "jobs": [ + { + "id": "weekly", + "agentId": "main", + "name": "Weekly", + "enabled": true, + "schedule": { "kind": "cron", "expr": "0 2 * * 1", "tz": "America/Chicago" } + }, + { + "id": "weekday", + "agentId": "main", + "name": "Weekday", + "enabled": true, + "schedule": { "kind": "cron", "expr": "15 10 * * 1-5", "tz": "America/Chicago" } + }, + { + "id": "monthly", + "agentId": "main", + "name": "Monthly", + "enabled": true, + "schedule": { "kind": "cron", "expr": "0 0 2 * *", "tz": "America/Chicago" } + } + ] + } + """ + try jobs.write(to: cronURL, atomically: true, encoding: .utf8) + try #"{"gateway":{"port":0}}"#.write(to: configURL, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(at: cronURL) + try? FileManager.default.removeItem(at: configURL) + } + + let store = MissionControlStore( + openClawConfigURL: configURL, + cronJobsURL: cronURL, + lazuliPortFileURL: URL(fileURLWithPath: "/tmp/missing-lazuli-port") + ) + store.start() + defer { store.stop() } + + let details = Dictionary(uniqueKeysWithValues: store.automations.map { ($0.id, $0.detail) }) + XCTAssertEqual(details["weekly"], "Mondays at 2:00 AM") + XCTAssertEqual(details["weekday"], "Weekdays at 10:15 AM") + XCTAssertEqual(details["monthly"], "Monthly on the 2nd at midnight") + } + + func testSessionPreviewPrefersLatestUserMessageOverAssistantMessage() { + let store = makeStore() + store.loadHalosSessionsForTesting(payload: [ + "sessions": [[ + "key": MissionControlStore.halosSessionKey, + "label": "Old label", + "status": "idle", + "lastUserMessagePreview": "My latest instruction", + "lastMessage": "Huma's latest answer", + "updatedAt": Date().timeIntervalSince1970 * 1000, + ]] + ]) + + XCTAssertEqual(store.halosSessions.first?.preview, "My latest instruction") + } + + func testSessionPreviewDoesNotUseAssistantLastMessageAsTitle() { + let store = makeStore() + store.loadHalosSessionsForTesting(payload: [ + "sessions": [[ + "key": MissionControlStore.halosSessionKey, + "label": "Halos", + "status": "idle", + "lastMessage": "Good. Flow's breathing.", + "derivedTitle": "Assistant-generated title", + "updatedAt": Date().timeIntervalSince1970 * 1000, + ]] + ]) + + XCTAssertEqual(store.halosSessions.first?.preview, "") + } + + func testSessionPreviewDoesNotUseHalosTimeLabelAsTitle() { + let store = makeStore() + store.loadHalosSessionsForTesting(payload: [ + "sessions": [[ + "key": MissionControlStore.halosSessionKey, + "label": "Halos 10:48 PM", + "status": "idle", + "updatedAt": Date().timeIntervalSince1970 * 1000, + ]] + ]) + + XCTAssertEqual(store.halosSessions.first?.preview, "") + } + + func testSessionsSortNewestToOldestAndSelectNewestOnStartup() { + let store = makeStore() + let now = Date().timeIntervalSince1970 * 1000 + store.loadHalosSessionsForTesting(payload: [ + "sessions": [ + [ + "key": "\(MissionControlStore.halosSessionKey):latest", + "label": "Latest", + "status": "idle", + "lastUserMessagePreview": "Newest", + "updatedAt": now, + ], + [ + "key": "\(MissionControlStore.halosSessionKey):earliest", + "label": "Earliest", + "status": "idle", + "lastUserMessagePreview": "Oldest", + "updatedAt": now - 10_000, + ], + ] + ]) + + XCTAssertEqual(store.halosSessions.map(\.key), [ + "\(MissionControlStore.halosSessionKey):latest", + "\(MissionControlStore.halosSessionKey):earliest", + ]) + XCTAssertEqual(store.currentSessionKey, "\(MissionControlStore.halosSessionKey):latest") + } + + func testSendingDraftImmediatelyUpdatesCurrentSessionTitleAndTime() { + let store = makeStore() + store.draft = "Rename this session from my message" + + store.sendDraft() + + XCTAssertEqual(store.currentSessionSummary.preview, "Rename this session from my message") + XCTAssertEqual(store.currentSessionSummary.updatedAtText, "now") + XCTAssertEqual(store.halosSessions.first?.preview, "Rename this session from my message") + XCTAssertEqual(store.halosSessions.first?.updatedAtText, "now") + } + + func testSendingFromSessionBrowserCreatesFreshSession() { + let store = makeStore() + store.loadHalosSessionsForTesting(payload: [ + "sessions": [[ + "key": "\(MissionControlStore.halosSessionKey):old", + "label": "Old", + "status": "idle", + "lastUserMessagePreview": "Old message", + "updatedAt": Date().timeIntervalSince1970 * 1000 - 10_000, + ]] + ]) + let previousKey = store.currentSessionKey + store.isCodeSessionBrowserPresented = true + store.draft = "This should start the new conversation" + + store.sendDraft() + + XCTAssertNotEqual(store.currentSessionKey, previousKey) + XCTAssertFalse(store.isCodeSessionBrowserPresented) + XCTAssertEqual(store.currentSessionSummary.preview, "This should start the new conversation") + XCTAssertEqual(store.messages.first(where: { $0.kind == .user })?.body, "This should start the new conversation") + } + + func testDeleteHalosSessionRemovesItLocallyAndSelectsNext() { + let store = makeStore() + let first = "\(MissionControlStore.halosSessionKey):first" + let second = "\(MissionControlStore.halosSessionKey):second" + let now = Date().timeIntervalSince1970 * 1000 + store.loadHalosSessionsForTesting(payload: [ + "sessions": [ + [ + "key": first, + "label": "First", + "status": "idle", + "lastUserMessagePreview": "First message", + "updatedAt": now - 10_000, + ], + [ + "key": second, + "label": "Second", + "status": "idle", + "lastUserMessagePreview": "Second message", + "updatedAt": now, + ], + ] + ]) + + store.deleteHalosSessionLocallyForTesting(first) + + XCTAssertEqual(store.halosSessions.map(\.key), [second]) + XCTAssertEqual(store.currentSessionKey, second) + XCTAssertTrue(store.isCodeSessionBrowserPresented) + } + + func testSlashCommandResultStaysOutOfTranscript() { + let store = makeStore() + let command = SlashCommand(command: "reasoning", description: "Show reasoning mode") + + store.beginSlashCommandForTesting(command, runID: "cmd-1") + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "cmd-1", state: "final", text: "Current reasoning level: off.\nOptions: on, off, stream.") + ) + + XCTAssertTrue(store.messages.isEmpty) + XCTAssertEqual(store.slashCommandPanelState?.command.displayCommand, "/reasoning") + XCTAssertEqual(store.slashCommandPanelState?.isRunning, false) + XCTAssertEqual(store.slashCommandPanelState?.output?.contains("Current reasoning level"), true) + XCTAssertEqual(store.slashCommandPanelState?.options, ["on", "off", "stream"]) + } + + func testSlashCommandEchoAndResultAreFilteredFromTranscript() { + let store = makeStore() + + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "cmd-1", state: "final", text: "/verbose", role: "user") + ) + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "cmd-2", state: "final", text: "Current verbose level: off.\nOptions: on, full, off.") + ) + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "cmd-3", state: "final", text: "Verbose logging disabled.") + ) + + XCTAssertTrue(store.messages.isEmpty) + } + + func testSlashCommandOptionSubmitDismissesPanel() { + let store = makeStore() + let command = SlashCommand(command: "verbose", description: "Set verbose mode") + + store.beginSlashCommandForTesting(command, runID: "cmd-1") + store.ingestGatewayEventForTesting( + event: "chat", + payload: chatPayload(runID: "cmd-1", state: "final", text: "Current verbose level: off.\nOptions: on, full, off.") + ) + XCTAssertEqual(store.slashCommandPanelState?.options, ["on", "full", "off"]) + + store.submitSlashCommandOption("on") + + XCTAssertNil(store.slashCommandPanelState) + XCTAssertTrue(store.messages.isEmpty) + } + + func testNewSlashCommandClearsTranscriptLocally() async throws { + let store = makeStore() + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "Old history")) + + store.runSlashCommand(SlashCommand(command: "new", description: "Start a fresh chat session")) + + XCTAssertTrue(store.messages.isEmpty) + XCTAssertNil(store.slashCommandPanelState) + + try await Task.sleep(for: .milliseconds(1900)) + XCTAssertNil(store.slashCommandPanelState) + } + private func makeStore() -> MissionControlStore { MissionControlStore( openClawConfigURL: URL(fileURLWithPath: "/tmp/missing-openclaw.json"), cronJobsURL: URL(fileURLWithPath: "/tmp/missing-cron.json"), - lazuliPortFileURL: URL(fileURLWithPath: "/tmp/missing-lazuli-port") + lazuliPortFileURL: URL(fileURLWithPath: "/tmp/missing-lazuli-port"), + veyraPortFileURL: URL(fileURLWithPath: "/tmp/missing-veyra-port") ) } - private func chatPayload(runID: String, state: String, text: String, sessionKey: String = MissionControlStore.halosSessionKey) -> [String: Any] { - [ + private func chatPayload( + runID: String, + state: String, + text: String, + sessionKey: String = MissionControlStore.halosSessionKey, + totalTokens: Int? = nil, + role: String = "assistant" + ) -> [String: Any] { + var payload: [String: Any] = [ "sessionKey": sessionKey, "runId": runID, "state": state, "message": [ - "role": "assistant", + "role": role, "content": [["type": "text", "text": text]], ], ] + if let totalTokens { + payload["usage"] = ["total_tokens": totalTokens] + } + return payload } - private func toolPayload(runID: String, phase: String, toolCallID: String, name: String) -> [String: Any] { - [ + private func toolPayload(runID: String, phase: String, toolCallID: String, name: String, data extraData: [String: Any] = [:]) -> [String: Any] { + var data = extraData + data["phase"] = phase + data["toolCallId"] = toolCallID + data["name"] = name + return [ "sessionKey": MissionControlStore.halosSessionKey, "runId": runID, "stream": "tool", + "data": data, + ] + } + + private func lifecyclePayload(runID: String, phase: String) -> [String: Any] { + [ + "sessionKey": MissionControlStore.halosSessionKey, + "runId": runID, + "stream": "lifecycle", "data": [ "phase": phase, - "toolCallId": toolCallID, - "name": name, ], ] } + + private func veyraApprovalEvent(status: String) -> String { + """ + { + "type": "approval.\(status == "pending" ? "requested" : "resolved")", + "approval": { + "id": "approval_123", + "draftId": "draft_123", + "providerId": "x", + "accountId": "x-main", + "status": "\(status)", + "riskLevel": "low", + "createdAt": "2026-04-25T19:00:00.000Z" + }, + "draft": { + "id": "draft_123", + "providerId": "x", + "accountId": "x-main", + "kind": "article", + "title": "Announcement", + "body": "Draft the announcement.", + "status": "approval_requested", + "riskLevel": "low", + "createdAt": "2026-04-25T19:00:00.000Z", + "updatedAt": "2026-04-25T19:00:00.000Z" + }, + "at": 1777050000000 + } + """ + } } diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns index 964cec2..cf9d971 100644 Binary files a/assets/AppIcon.icns and b/assets/AppIcon.icns differ diff --git a/assets/AppIcon.icon/Assets/halos-dots.png b/assets/AppIcon.icon/Assets/halos-dots.png index 16e2c13..5c9a1b3 100644 Binary files a/assets/AppIcon.icon/Assets/halos-dots.png and b/assets/AppIcon.icon/Assets/halos-dots.png differ diff --git a/assets/AppIcon.icon/icon.json b/assets/AppIcon.icon/icon.json index 7522b8e..0aa730b 100644 --- a/assets/AppIcon.icon/icon.json +++ b/assets/AppIcon.icon/icon.json @@ -9,7 +9,7 @@ "image-name" : "halos-dots.png", "name" : "halos-dots", "position" : { - "scale" : 0.97999999999999998, + "scale" : 1.0800000000000001, "translation-in-points" : [ 0, 0 diff --git a/assets/AppIcon.iconset/icon_128x128.png b/assets/AppIcon.iconset/icon_128x128.png index f52b5e8..3e26f52 100644 Binary files a/assets/AppIcon.iconset/icon_128x128.png and b/assets/AppIcon.iconset/icon_128x128.png differ diff --git a/assets/AppIcon.iconset/icon_128x128@2x.png b/assets/AppIcon.iconset/icon_128x128@2x.png index 16511a3..012dc18 100644 Binary files a/assets/AppIcon.iconset/icon_128x128@2x.png and b/assets/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/assets/AppIcon.iconset/icon_16x16.png b/assets/AppIcon.iconset/icon_16x16.png index 47a6d43..bb57156 100644 Binary files a/assets/AppIcon.iconset/icon_16x16.png and b/assets/AppIcon.iconset/icon_16x16.png differ diff --git a/assets/AppIcon.iconset/icon_16x16@2x.png b/assets/AppIcon.iconset/icon_16x16@2x.png index 7128e97..8241d47 100644 Binary files a/assets/AppIcon.iconset/icon_16x16@2x.png and b/assets/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/assets/AppIcon.iconset/icon_256x256.png b/assets/AppIcon.iconset/icon_256x256.png index 16511a3..012dc18 100644 Binary files a/assets/AppIcon.iconset/icon_256x256.png and b/assets/AppIcon.iconset/icon_256x256.png differ diff --git a/assets/AppIcon.iconset/icon_256x256@2x.png b/assets/AppIcon.iconset/icon_256x256@2x.png index 53fc77d..b08bdb1 100644 Binary files a/assets/AppIcon.iconset/icon_256x256@2x.png and b/assets/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/assets/AppIcon.iconset/icon_32x32.png b/assets/AppIcon.iconset/icon_32x32.png index 7128e97..8241d47 100644 Binary files a/assets/AppIcon.iconset/icon_32x32.png and b/assets/AppIcon.iconset/icon_32x32.png differ diff --git a/assets/AppIcon.iconset/icon_32x32@2x.png b/assets/AppIcon.iconset/icon_32x32@2x.png index ed2241e..7c18ebb 100644 Binary files a/assets/AppIcon.iconset/icon_32x32@2x.png and b/assets/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/assets/AppIcon.iconset/icon_512x512.png b/assets/AppIcon.iconset/icon_512x512.png index 53fc77d..b08bdb1 100644 Binary files a/assets/AppIcon.iconset/icon_512x512.png and b/assets/AppIcon.iconset/icon_512x512.png differ diff --git a/assets/AppIcon.iconset/icon_512x512@2x.png b/assets/AppIcon.iconset/icon_512x512@2x.png index a54047f..82770c2 100644 Binary files a/assets/AppIcon.iconset/icon_512x512@2x.png and b/assets/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 870ca9a..e1ccfd2 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -10,6 +10,8 @@ MIN_SYSTEM_VERSION="14.0" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" APP_BUNDLE="$DIST_DIR/$APP_NAME.app" +INSTALL_DIR="$HOME/Applications" +INSTALLED_APP_BUNDLE="$INSTALL_DIR/$APP_NAME.app" APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" APP_RESOURCES="$APP_CONTENTS/Resources" @@ -77,7 +79,11 @@ PLIST } open_app() { - /usr/bin/open -n "$APP_BUNDLE" + mkdir -p "$INSTALL_DIR" + rm -rf "$INSTALLED_APP_BUNDLE" + cp -R "$APP_BUNDLE" "$INSTALLED_APP_BUNDLE" + rm -rf "$APP_BUNDLE" + /usr/bin/open "$INSTALLED_APP_BUNDLE" } build_app diff --git a/script/generate_app_icon.swift b/script/generate_app_icon.swift index b47d991..6c47689 100755 --- a/script/generate_app_icon.swift +++ b/script/generate_app_icon.swift @@ -28,7 +28,21 @@ let sizes: [(name: String, pixels: Int)] = [ ("icon_512x512@2x.png", 1024), ] -func drawIcon(pixels: Int) throws { +let supersampleFactor = 8 +let markStep: CGFloat = 220 +let markRadius: CGFloat = 82 + +let dotPalette: [NSColor] = [ + NSColor(calibratedRed: 0.270, green: 0.110, blue: 0.620, alpha: 1.0), + NSColor(calibratedRed: 0.330, green: 0.160, blue: 0.760, alpha: 1.0), + NSColor(calibratedRed: 0.200, green: 0.270, blue: 0.860, alpha: 1.0), + NSColor(calibratedRed: 0.365, green: 0.300, blue: 0.940, alpha: 1.0), + NSColor(calibratedRed: 0.285, green: 0.480, blue: 0.980, alpha: 1.0), + NSColor(calibratedRed: 0.520, green: 0.440, blue: 1.000, alpha: 1.0), + NSColor(calibratedRed: 0.630, green: 0.650, blue: 1.000, alpha: 1.0), +] + +func makeBitmap(pixels: Int) throws -> NSBitmapImageRep { guard let bitmap = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: pixels, @@ -43,6 +57,84 @@ func drawIcon(pixels: Int) throws { ) else { throw NSError(domain: "HalosIcon", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not create icon bitmap"]) } + return bitmap +} + +func downsample(_ source: NSBitmapImageRep, to pixels: Int) throws -> NSBitmapImageRep { + let target = try makeBitmap(pixels: pixels) + let image = NSImage(size: NSSize(width: source.pixelsWide, height: source.pixelsHigh)) + image.addRepresentation(source) + + let context = NSGraphicsContext(bitmapImageRep: target) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context?.imageInterpolation = .high + image.draw( + in: NSRect(origin: .zero, size: NSSize(width: pixels, height: pixels)), + from: NSRect(origin: .zero, size: image.size), + operation: .copy, + fraction: 1.0 + ) + NSGraphicsContext.restoreGraphicsState() + + return target +} + +func markCenters(in rect: NSRect, scale: CGFloat) -> [CGPoint] { + let horizontalSpacing = markStep * scale + let verticalSpacing = markStep * scale + return [ + CGPoint(x: rect.midX - horizontalSpacing / 2, y: rect.midY + verticalSpacing), + CGPoint(x: rect.midX + horizontalSpacing / 2, y: rect.midY + verticalSpacing), + CGPoint(x: rect.midX - horizontalSpacing, y: rect.midY), + CGPoint(x: rect.midX, y: rect.midY), + CGPoint(x: rect.midX + horizontalSpacing, y: rect.midY), + CGPoint(x: rect.midX - horizontalSpacing / 2, y: rect.midY - verticalSpacing), + CGPoint(x: rect.midX + horizontalSpacing / 2, y: rect.midY - verticalSpacing), + ] +} + +func drawDots(in rect: NSRect, scale: CGFloat) { + let dotRadius = markRadius * scale + let centers = markCenters(in: rect, scale: scale) + + for (index, center) in centers.enumerated() { + let fill = dotPalette[min(index, dotPalette.count - 1)] + let shadow = NSShadow() + shadow.shadowBlurRadius = 16 * scale + shadow.shadowOffset = .zero + shadow.shadowColor = fill.withAlphaComponent(0.42) + + NSGraphicsContext.saveGraphicsState() + shadow.set() + fill.setFill() + NSBezierPath(ovalIn: NSRect( + x: center.x - dotRadius, + y: center.y - dotRadius, + width: dotRadius * 2, + height: dotRadius * 2 + )).fill() + NSGraphicsContext.restoreGraphicsState() + + let highlight = NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.34), + NSColor.white.withAlphaComponent(0.0), + ]) + highlight?.draw( + in: NSBezierPath(ovalIn: NSRect( + x: center.x - dotRadius, + y: center.y - dotRadius, + width: dotRadius * 2, + height: dotRadius * 2 + )), + angle: 62 + ) + } +} + +func drawIcon(pixels: Int) throws { + let renderPixels = pixels * supersampleFactor + let bitmap = try makeBitmap(pixels: renderPixels) let context = NSGraphicsContext(bitmapImageRep: bitmap) NSGraphicsContext.saveGraphicsState() @@ -51,8 +143,8 @@ func drawIcon(pixels: Int) throws { NSGraphicsContext.restoreGraphicsState() } - let rect = NSRect(origin: .zero, size: NSSize(width: pixels, height: pixels)) - let scale = CGFloat(pixels) / 1024 + let rect = NSRect(origin: .zero, size: NSSize(width: renderPixels, height: renderPixels)) + let scale = CGFloat(renderPixels) / 1024 context?.imageInterpolation = .high let gradient = NSGradient(colors: [ @@ -68,45 +160,10 @@ func drawIcon(pixels: Int) throws { ]) vignette?.draw(in: rect.insetBy(dx: -160 * scale, dy: -160 * scale), relativeCenterPosition: NSPoint(x: 0, y: -0.22)) - let dotRadius = 88 * scale - let spacing = 210 * scale - let dots: [CGPoint] = [ - CGPoint(x: rect.midX - spacing / 2, y: rect.midY + spacing), - CGPoint(x: rect.midX + spacing / 2, y: rect.midY + spacing), - CGPoint(x: rect.midX - spacing, y: rect.midY), - CGPoint(x: rect.midX, y: rect.midY), - CGPoint(x: rect.midX + spacing, y: rect.midY), - CGPoint(x: rect.midX - spacing / 2, y: rect.midY - spacing), - CGPoint(x: rect.midX + spacing / 2, y: rect.midY - spacing), - ] - - for center in dots { - let shadow = NSShadow() - shadow.shadowBlurRadius = 18 * scale - shadow.shadowOffset = .zero - shadow.shadowColor = NSColor.white.withAlphaComponent(0.18) - - NSGraphicsContext.saveGraphicsState() - shadow.set() - NSColor(calibratedWhite: 0.92, alpha: 1.0).setFill() - NSBezierPath(ovalIn: NSRect( - x: center.x - dotRadius, - y: center.y - dotRadius, - width: dotRadius * 2, - height: dotRadius * 2 - )).fill() - NSGraphicsContext.restoreGraphicsState() - - NSColor.white.withAlphaComponent(0.18).setFill() - NSBezierPath(ovalIn: NSRect( - x: center.x - dotRadius * 0.48, - y: center.y + dotRadius * 0.12, - width: dotRadius * 0.46, - height: dotRadius * 0.28 - )).fill() - } + drawDots(in: rect, scale: scale) - guard let png = bitmap.representation(using: .png, properties: [:]) else { + let finalBitmap = try downsample(bitmap, to: pixels) + guard let png = finalBitmap.representation(using: .png, properties: [:]) else { throw NSError(domain: "HalosIcon", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not render icon PNG"]) } @@ -117,20 +174,8 @@ func drawIcon(pixels: Int) throws { func drawForegroundLayer() throws { let pixels = 1024 - guard let bitmap = NSBitmapImageRep( - bitmapDataPlanes: nil, - pixelsWide: pixels, - pixelsHigh: pixels, - bitsPerSample: 8, - samplesPerPixel: 4, - hasAlpha: true, - isPlanar: false, - colorSpaceName: .deviceRGB, - bytesPerRow: 0, - bitsPerPixel: 0 - ) else { - throw NSError(domain: "HalosIcon", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not create foreground bitmap"]) - } + let renderPixels = pixels * supersampleFactor + let bitmap = try makeBitmap(pixels: renderPixels) let context = NSGraphicsContext(bitmapImageRep: bitmap) NSGraphicsContext.saveGraphicsState() @@ -139,49 +184,15 @@ func drawForegroundLayer() throws { NSGraphicsContext.restoreGraphicsState() } - let rect = NSRect(origin: .zero, size: NSSize(width: pixels, height: pixels)) + let rect = NSRect(origin: .zero, size: NSSize(width: renderPixels, height: renderPixels)) NSColor.clear.setFill() rect.fill() - let dotRadius: CGFloat = 88 - let spacing: CGFloat = 210 - let dots: [CGPoint] = [ - CGPoint(x: rect.midX - spacing / 2, y: rect.midY + spacing), - CGPoint(x: rect.midX + spacing / 2, y: rect.midY + spacing), - CGPoint(x: rect.midX - spacing, y: rect.midY), - CGPoint(x: rect.midX, y: rect.midY), - CGPoint(x: rect.midX + spacing, y: rect.midY), - CGPoint(x: rect.midX - spacing / 2, y: rect.midY - spacing), - CGPoint(x: rect.midX + spacing / 2, y: rect.midY - spacing), - ] - - for center in dots { - let shadow = NSShadow() - shadow.shadowBlurRadius = 18 - shadow.shadowOffset = .zero - shadow.shadowColor = NSColor.white.withAlphaComponent(0.18) - - NSGraphicsContext.saveGraphicsState() - shadow.set() - NSColor(calibratedWhite: 0.92, alpha: 1.0).setFill() - NSBezierPath(ovalIn: NSRect( - x: center.x - dotRadius, - y: center.y - dotRadius, - width: dotRadius * 2, - height: dotRadius * 2 - )).fill() - NSGraphicsContext.restoreGraphicsState() - - NSColor.white.withAlphaComponent(0.18).setFill() - NSBezierPath(ovalIn: NSRect( - x: center.x - dotRadius * 0.48, - y: center.y + dotRadius * 0.12, - width: dotRadius * 0.46, - height: dotRadius * 0.28 - )).fill() - } + let scale = CGFloat(renderPixels) / 1024 + drawDots(in: rect, scale: scale) - guard let png = bitmap.representation(using: .png, properties: [:]) else { + let finalBitmap = try downsample(bitmap, to: pixels) + guard let png = finalBitmap.representation(using: .png, properties: [:]) else { throw NSError(domain: "HalosIcon", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not render foreground PNG"]) } try png.write(to: foregroundURL) @@ -204,7 +215,7 @@ let document: [String: Any] = [ "image-name": "halos-dots.png", "name": "halos-dots", "position": [ - "scale": 0.98, + "scale": 1.08, "translation-in-points": [0, 0], ], ],