Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions Sources/Halos/Models/LazuliEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,11 @@ public enum GatewayStreamFormatter {
if lower.hasPrefix("edoras_safari__") || lower.contains("edoras") {
return ToolDisplay(name: "Browser Control", category: .plugin)
}
if lower.contains("veyra") {
return ToolDisplay(name: "Veyra", category: .plugin)
}
if lower.hasPrefix("plugin__") || lower.hasPrefix("plugin_") || lower.hasPrefix("plugin:") {
return ToolDisplay(name: titleizedToolName(raw), category: .plugin)
return ToolDisplay(name: pluginSurfaceName(raw), category: .plugin)
}
if lower.contains("terminal") || lower == "bash" || lower == "shell" || lower == "exec" {
return ToolDisplay(name: "Terminal", category: .tool)
Expand All @@ -265,18 +268,35 @@ public enum GatewayStreamFormatter {

private static func titleizedToolName(_ raw: String) -> String {
raw
.replacingOccurrences(of: "plugin__", with: "")
.replacingOccurrences(of: "plugin_", with: "")
.replacingOccurrences(of: "plugin:", with: "")
.replacingOccurrences(of: "plugin__", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "plugin_", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "plugin:", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "__", with: " ")
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: ".", with: " ")
.split(separator: " ")
.map { part in
part.prefix(1).uppercased() + part.dropFirst()
}
.joined(separator: " ")
}

private static func pluginSurfaceName(_ raw: String) -> String {
let stripped = raw
.replacingOccurrences(of: "plugin__", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "plugin_", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "plugin:", with: "", options: .caseInsensitive)
let surface = stripped
.components(separatedBy: "__")
.first?
.components(separatedBy: ".")
.first?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let surface, !surface.isEmpty else { return "Plugin" }
return titleizedToolName(surface)
}

public static func toolBody(phase: String?, name: String?, data: [String: Any]) -> String {
let display = toolDisplay(name)
if display.category == .plugin {
Expand Down Expand Up @@ -462,14 +482,20 @@ public enum GatewayStreamFormatter {
}

private static func pluginActionName(_ name: String?) -> String {
let raw = (name ?? "action").lowercased()
let stripped = (name ?? "action").lowercased()
.replacingOccurrences(of: "edoras_safari__", with: "")
.replacingOccurrences(of: "lazuli_", with: "")
.replacingOccurrences(of: "veyra_", with: "")
.replacingOccurrences(of: "plugin__", with: "")
.replacingOccurrences(of: "plugin_", with: "")
.replacingOccurrences(of: "plugin:", with: "")
.replacingOccurrences(of: "safari_", with: "")

let raw = stripped
.components(separatedBy: "__")
.last?
.components(separatedBy: ".")
.last ?? stripped
let normalized = raw.replacingOccurrences(of: "-", with: "_")
switch normalized {
case "click", "click_at", "double_click_at", "triple_click_at":
Expand Down
47 changes: 43 additions & 4 deletions Sources/Halos/Stores/MissionControlStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ private struct LocalSessionTitleOverride {
let updatedAt: Date
}

private struct HalosSessionLocalState: Codable {
var deletedSessionKeys: Set<String> = []
}

@MainActor
public final class MissionControlStore: ObservableObject {
public static let halosSessionKey = "agent:main:halos"
Expand All @@ -337,6 +341,7 @@ public final class MissionControlStore: ObservableObject {
private let cronJobsURL: URL
private let lazuliPortFileURL: URL
private let veyraPortFileURL: URL
private let sessionStateURL: URL
private var gatewayTask: URLSessionWebSocketTask?
private var lazuliTask: URLSessionWebSocketTask?
private var veyraTask: URLSessionWebSocketTask?
Expand All @@ -363,18 +368,22 @@ public final class MissionControlStore: ObservableObject {
private var sessionGeneration = 0
private var didSelectInitialHalosSession = false
private var localSessionTitleOverrides: [String: LocalSessionTitleOverride] = [:]
private var deletedSessionKeys: Set<String> = []

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"),
veyraPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".veyra/current-port")
veyraPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".veyra/current-port"),
sessionStateURL: URL? = nil
) {
self.openClawConfigURL = openClawConfigURL
self.cronJobsURL = cronJobsURL
self.lazuliPortFileURL = lazuliPortFileURL
self.veyraPortFileURL = veyraPortFileURL
self.sessionStateURL = sessionStateURL ?? HalosStorage.sessionStateURL
self.slashCommands = Self.loadHalosSlashCommands(from: openClawConfigURL)
self.deletedSessionKeys = Self.loadSessionLocalState(from: self.sessionStateURL).deletedSessionKeys
}

public func start() {
Expand Down Expand Up @@ -672,6 +681,8 @@ public final class MissionControlStore: ObservableObject {

private func deleteHalosSessionLocally(_ key: String) {
let remainingSessions = halosSessions.filter { $0.key != key }
deletedSessionKeys.insert(key)
saveSessionLocalState()
halosSessions = remainingSessions
localSessionTitleOverrides.removeValue(forKey: key)

Expand Down Expand Up @@ -1288,6 +1299,9 @@ public final class MissionControlStore: ObservableObject {
else {
return nil
}
guard !deletedSessionKeys.contains(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)
Expand Down Expand Up @@ -1344,6 +1358,28 @@ public final class MissionControlStore: ObservableObject {
key == Self.halosSessionKey || key.hasPrefix("\(Self.halosSessionKey):")
}

private static func loadSessionLocalState(from url: URL) -> HalosSessionLocalState {
guard
let data = try? Data(contentsOf: url),
let state = try? JSONDecoder().decode(HalosSessionLocalState.self, from: data)
else {
return HalosSessionLocalState()
}
return state
}

private func saveSessionLocalState() {
do {
try HalosStorage.ensureLayout()
let state = HalosSessionLocalState(deletedSessionKeys: deletedSessionKeys)
let data = try JSONEncoder().encode(state)
try FileManager.default.createDirectory(at: sessionStateURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: sessionStateURL, options: [.atomic])
} catch {
// Session tombstones are a UI safety net; the gateway delete remains authoritative.
}
}

private static func defaultSessionLabel(for key: String) -> String {
guard key != halosSessionKey else { return "Halos" }
let suffix = key.replacingOccurrences(of: "\(halosSessionKey):", with: "")
Expand Down Expand Up @@ -2257,12 +2293,15 @@ public final class MissionControlStore: ObservableObject {
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 }
@discardableResult
private func closeToolSummarySegmentIfNeeded(runID: String) -> Bool {
guard let rollup = runToolRollupsByRunID[runID], rollup.hasVisibleToolActivity else { return false }
guard rollup.activeTools.isEmpty else { return false }
finalizeWorkingSummary(runID: runID)
runToolRollupsByRunID.removeValue(forKey: runID)
workingMessageIDsByRunID.removeValue(forKey: runID)
assistantMessageIDsByRunID.removeValue(forKey: runID)
return true
}

private func handleGatewayDisconnect() {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Halos/Support/HalosStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ enum HalosStorage {
rootURL.appending(path: "State", directoryHint: .isDirectory)
}

static var sessionStateURL: URL {
stateURL.appending(path: "sessions.json")
}

static var gatewayURL: URL {
stateURL.appending(path: "Gateway", directoryHint: .isDirectory)
}
Expand Down
73 changes: 64 additions & 9 deletions Sources/Halos/Views/HalosControlWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private struct CollapsingHeaderTitle: View {
private var compactWidth: CGFloat {
switch title {
case "Automations":
return 78
return 76
case "Settings":
return 48
default:
Expand All @@ -168,7 +168,8 @@ private struct CollapsingHeaderTitle: View {
.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)
.matchedGeometryEffect(id: id, in: namespace, properties: .frame, anchor: .leading)
.animation(.smooth(duration: 0.22), value: isCompact)
}
}

Expand Down Expand Up @@ -304,6 +305,7 @@ private struct ClosedSidebarSurface: View {
closedBreadcrumb
.padding(.leading, 2)
}
.frame(height: 29, alignment: .leading)
.animation(.smooth(duration: 0.18), value: closedBreadcrumbText)
}

Expand Down Expand Up @@ -1190,9 +1192,10 @@ private struct WorkbenchColumnView: View {
draggedMode: $draggedMode,
layout: $layout
)
.frame(maxHeight: .infinity)
} else if let firstMode = modes.first, modes.count > 1 {
GeometryReader { proxy in
let handleHeight: CGFloat = 8
let handleHeight: CGFloat = 14
let availableHeight = max(0, proxy.size.height - handleHeight)
let minimumRatio = min(0.45, 180 / max(availableHeight, 360))
let effectiveRatio = max(minimumRatio, min(1 - minimumRatio, splitRatio))
Expand Down Expand Up @@ -1249,6 +1252,7 @@ private struct WorkbenchColumnView: View {
.frame(height: secondHeight)
}
}
.frame(maxHeight: .infinity)
}
}
}
Expand Down Expand Up @@ -1380,7 +1384,7 @@ private struct WorkbenchHorizontalResizeHandle: View {
.background(Color.clear)
.overlay {
Capsule()
.fill(isHovering ? HalosTheme.secondaryText.opacity(0.62) : HalosTheme.separator.opacity(0.82))
.fill(isHovering ? HalosTheme.secondaryText.opacity(0.62) : Color.clear)
.frame(width: 46, height: 2)
}
.contentShape(Rectangle())
Expand Down Expand Up @@ -2515,6 +2519,8 @@ private enum ComposerFileSearch {
private enum ComposerAttachment {
static let acceptedTypes: [UTType] = [
.fileURL,
.url,
.plainText,
.png,
.jpeg,
.tiff,
Expand All @@ -2540,14 +2546,26 @@ private enum ComposerAttachment {
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 {
if let url = url(from: item), url.isFileURL {
completion(url)
return
}
if let data = item as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) {
completion(url)
}
}
return
}

if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
guard let url = url(from: item), url.isFileURL else { return }
completion(url)
}
return
}

if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
guard let url = fileURLFromPlainTextItem(item) else { return }
completion(url)
}
return
}
Expand Down Expand Up @@ -2576,6 +2594,43 @@ private enum ComposerAttachment {
]
}

private static func url(from item: NSSecureCoding?) -> URL? {
if let url = item as? URL {
return url
}
if let data = item as? Data {
return URL(dataRepresentation: data, relativeTo: nil)
}
if let string = item as? String {
return URL(string: string) ?? existingFileURL(from: string)
}
return nil
}

private static func fileURLFromPlainTextItem(_ item: NSSecureCoding?) -> URL? {
if let data = item as? Data,
let string = String(data: data, encoding: .utf8) {
return existingFileURL(from: string)
}
if let string = item as? String {
return existingFileURL(from: string)
}
return nil
}

private static func existingFileURL(from raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let url: URL
if let parsed = URL(string: trimmed), parsed.isFileURL {
url = parsed
} else {
url = URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath)
}
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return url
}

private static func save(data: Data, contentType: UTType) -> URL? {
let directory = HalosStorage.attachmentsURL
do {
Expand Down
Loading
Loading