mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:01:35 +00:00
* feat(native): show swarm progress * fix(native): align swarm progress lifecycle * chore(native): shorten swarm progress source name * style(macos): pad swarm progress surfaces * chore(native): use compact swarm source name * fix(native): harden swarm route ownership * fix(native): preserve swarm render ownership * chore(i18n): refresh native swarm inventory * fix(native): bound swarm refresh ownership * fix(native): remove unused swarm capability initializer
537 lines
22 KiB
Swift
537 lines
22 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OpenClawChatUI
|
|
|
|
/// A borderless panel that can still accept key focus (needed for typing).
|
|
final class WebChatPanel: NSPanel {
|
|
override var canBecomeKey: Bool {
|
|
true
|
|
}
|
|
|
|
override var canBecomeMain: Bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
enum WebChatPresentation {
|
|
case window
|
|
case panel(anchorProvider: () -> NSRect?)
|
|
}
|
|
|
|
struct WebChatRoute: Equatable, Sendable {
|
|
let sessionKey: String
|
|
let agentID: String?
|
|
|
|
init(sessionKey: String, agentID: String?) {
|
|
self.sessionKey = sessionKey
|
|
self.agentID = Self.normalizedAgentID(agentID)
|
|
}
|
|
|
|
func replacingSessionKey(_ sessionKey: String) -> Self {
|
|
Self(sessionKey: sessionKey, agentID: self.agentID)
|
|
}
|
|
|
|
static func normalizedAgentID(_ agentID: String?) -> String? {
|
|
let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
}
|
|
|
|
struct WebChatSessionObserverVisibilityOwners {
|
|
private var ownersByConnection: [ObjectIdentifier: Set<ObjectIdentifier>] = [:]
|
|
|
|
mutating func setVisible(
|
|
_ visible: Bool,
|
|
owner: ObjectIdentifier,
|
|
connection: ObjectIdentifier) -> Bool?
|
|
{
|
|
let wasVisible = self.isVisible(connection: connection)
|
|
if visible {
|
|
self.ownersByConnection[connection, default: []].insert(owner)
|
|
} else {
|
|
self.ownersByConnection[connection]?.remove(owner)
|
|
if self.ownersByConnection[connection]?.isEmpty == true {
|
|
self.ownersByConnection.removeValue(forKey: connection)
|
|
}
|
|
}
|
|
let isVisible = self.isVisible(connection: connection)
|
|
return wasVisible == isVisible ? nil : isVisible
|
|
}
|
|
|
|
func isVisible(connection: ObjectIdentifier) -> Bool {
|
|
self.ownersByConnection[connection]?.isEmpty == false
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WebChatManager {
|
|
static let shared = WebChatManager()
|
|
|
|
private struct ProfileWindowInstance {
|
|
let profileID: String
|
|
let controller: WebChatSwiftUIWindowController
|
|
}
|
|
|
|
private var windowController: WebChatSwiftUIWindowController?
|
|
private var windowRoute: WebChatRoute?
|
|
private var panelController: WebChatSwiftUIWindowController?
|
|
private var panelRoute: WebChatRoute?
|
|
private var currentChatRoute: WebChatRoute?
|
|
private var cachedPreferredSessionKey: String?
|
|
private var profileWindows: [UUID: ProfileWindowInstance] = [:]
|
|
private var profileWindowOrder: [UUID] = []
|
|
private var unavailableProfileIDs: Set<String> = []
|
|
private var sessionObserverOwners = WebChatSessionObserverVisibilityOwners()
|
|
private var sessionObserverMonitors: [ObjectIdentifier: Task<Void, Never>] = [:]
|
|
private var sessionObserverRequests: [ObjectIdentifier: (id: UUID, task: Task<Void, Never>)] = [:]
|
|
private var sessionObserverDeclarations:
|
|
[ObjectIdentifier: (lease: GatewayConnection.ServerLease, visible: Bool)] = [:]
|
|
|
|
private static let lastGatewayProfileIDKey = "openclaw.webchat.lastGatewayProfileID"
|
|
|
|
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
|
|
|
var activeSessionKey: String? {
|
|
self.currentChatRoute?.sessionKey ?? self.panelRoute?.sessionKey ?? self.windowRoute?.sessionKey
|
|
}
|
|
|
|
func show(sessionKey: String, agentID: String? = nil, draft: String? = nil) {
|
|
let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID)
|
|
self.closePanel()
|
|
if let controller = windowController {
|
|
// The window shell switches sessions in place (sidebar, /new);
|
|
// full route identity tracks those switches and the global owner.
|
|
if Self.shouldReuseController(currentRoute: self.windowRoute, requestedRoute: route) {
|
|
controller.applyDraftIfEmpty(draft)
|
|
controller.show()
|
|
return
|
|
}
|
|
|
|
controller.close()
|
|
self.windowController = nil
|
|
self.windowRoute = nil
|
|
}
|
|
let controller = WebChatSwiftUIWindowController(
|
|
sessionKey: route.sessionKey,
|
|
agentID: route.agentID,
|
|
initialDraft: draft,
|
|
presentation: .window)
|
|
controller.onVisibilityChanged = { [weak self, weak controller] visible in
|
|
guard let self, let controller else { return }
|
|
self.setSessionObserverVisible(visible, owner: controller, connection: .shared)
|
|
self.onPanelVisibilityChanged?(visible)
|
|
}
|
|
controller.onClosed = { [weak self, weak controller] in
|
|
guard let self, let controller else { return }
|
|
self.setSessionObserverVisible(false, owner: controller, connection: .shared)
|
|
guard self.windowController === controller else { return }
|
|
if self.currentChatRoute == self.windowRoute {
|
|
self.currentChatRoute = self.panelRoute
|
|
}
|
|
self.windowController = nil
|
|
self.windowRoute = nil
|
|
}
|
|
controller.onSessionKeyChanged = { [weak self, weak controller] key in
|
|
guard let self, let controller, self.windowController === controller else { return }
|
|
// Retaining the agent is safe: this surface has no in-window agent switcher,
|
|
// and the controller pins explicit agents against gateway-default changes.
|
|
let updatedRoute = (self.windowRoute ?? route).replacingSessionKey(key)
|
|
self.windowRoute = updatedRoute
|
|
self.currentChatRoute = updatedRoute
|
|
}
|
|
self.windowController = controller
|
|
self.windowRoute = route
|
|
self.currentChatRoute = route
|
|
controller.show()
|
|
}
|
|
|
|
#if DEBUG
|
|
func showSwarmFixture() {
|
|
self.closePanel()
|
|
self.windowController?.close()
|
|
let transport = MacSwarmFixtureChatTransport()
|
|
let controller = WebChatSwiftUIWindowController(
|
|
sessionKey: transport.sessionKey,
|
|
presentation: .window,
|
|
transport: transport,
|
|
windowTitle: "OpenClaw Swarm Fixture",
|
|
windowAutosaveName: "OpenClawSwarmFixture")
|
|
controller.onClosed = { [weak self, weak controller] in
|
|
guard let self, let controller, self.windowController === controller else { return }
|
|
self.windowController = nil
|
|
self.windowRoute = nil
|
|
}
|
|
self.windowController = controller
|
|
self.windowRoute = WebChatRoute(sessionKey: transport.sessionKey, agentID: nil)
|
|
controller.show()
|
|
}
|
|
#endif
|
|
|
|
func newGatewayWindow() {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let profiles = try await MacGatewayProfileStore.shared.profiles()
|
|
guard !profiles.isEmpty else {
|
|
AppNavigationActions.openSettings(tab: .gateways)
|
|
return
|
|
}
|
|
let preferredID = UserDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
|
|
switch Self.promptForGatewayProfile(profiles: profiles, preferredID: preferredID) {
|
|
case let .profile(profile):
|
|
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
|
try await self.show(profile: profile)
|
|
case .manage:
|
|
AppNavigationActions.openSettings(tab: .gateways)
|
|
case nil:
|
|
break
|
|
}
|
|
} catch {
|
|
Self.showProfileError(error, message: "Could Not Open Gateway Window")
|
|
}
|
|
}
|
|
}
|
|
|
|
func openGatewayWindow(profile: MacGatewayProfile) {
|
|
Task { @MainActor [weak self] in
|
|
do {
|
|
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
|
try await self?.show(profile: profile)
|
|
} catch {
|
|
Self.showProfileError(error, message: "Could Not Open Gateway Window")
|
|
}
|
|
}
|
|
}
|
|
|
|
func show(profile: MacGatewayProfile) async throws {
|
|
guard !self.unavailableProfileIDs.contains(profile.id) else {
|
|
throw MacGatewayProfileError.profileNotFound
|
|
}
|
|
let connection = await MacGatewayConnectionFleet.shared.connection(profileID: profile.id)
|
|
guard !self.unavailableProfileIDs.contains(profile.id) else {
|
|
throw MacGatewayProfileError.profileNotFound
|
|
}
|
|
let sessionKey = await connection.mainSessionKey()
|
|
guard !self.unavailableProfileIDs.contains(profile.id) else {
|
|
throw MacGatewayProfileError.profileNotFound
|
|
}
|
|
let windowID = UUID()
|
|
let route = WebChatRoute(sessionKey: sessionKey, agentID: nil)
|
|
let previousController = self.profileWindowOrder.reversed().lazy
|
|
.compactMap { self.profileWindows[$0] }
|
|
.first { $0.profileID == profile.id }?
|
|
.controller
|
|
let controller = WebChatSwiftUIWindowController(
|
|
sessionKey: route.sessionKey,
|
|
agentID: route.agentID,
|
|
presentation: .window,
|
|
connection: connection,
|
|
gatewayID: profile.id,
|
|
windowTitle: "\(profile.name) — OpenClaw",
|
|
windowAutosaveName: "OpenClawChatWindow-\(profile.id)")
|
|
controller.onVisibilityChanged = { [weak self, weak controller] visible in
|
|
guard let self, let controller else { return }
|
|
self.setSessionObserverVisible(visible, owner: controller, connection: connection)
|
|
}
|
|
controller.onClosed = { [weak self, weak controller] in
|
|
guard let self, let controller else { return }
|
|
self.setSessionObserverVisible(false, owner: controller, connection: connection)
|
|
guard self.profileWindows[windowID]?.controller === controller else { return }
|
|
self.profileWindows.removeValue(forKey: windowID)
|
|
self.profileWindowOrder.removeAll { $0 == windowID }
|
|
}
|
|
self.profileWindows[windowID] = ProfileWindowInstance(
|
|
profileID: profile.id,
|
|
controller: controller)
|
|
self.profileWindowOrder.append(windowID)
|
|
controller.cascade(from: previousController)
|
|
controller.show()
|
|
Task {
|
|
try? await connection.refresh()
|
|
}
|
|
}
|
|
|
|
func closeGatewayWindows(profileID: String) async {
|
|
// Removal fences in-flight window creation before awaiting connection
|
|
// shutdown, so an old picker selection cannot resurrect this profile.
|
|
self.unavailableProfileIDs.insert(profileID)
|
|
let windowIDs = self.profileWindowOrder.filter { self.profileWindows[$0]?.profileID == profileID }
|
|
let controllers = windowIDs.compactMap { self.profileWindows.removeValue(forKey: $0)?.controller }
|
|
let windowIDSet = Set(windowIDs)
|
|
self.profileWindowOrder.removeAll { windowIDSet.contains($0) }
|
|
for controller in controllers {
|
|
controller.close()
|
|
}
|
|
await MacGatewayConnectionFleet.shared.remove(profileID: profileID)
|
|
}
|
|
|
|
func gatewayProfileDidSave(profileID: String) {
|
|
self.unavailableProfileIDs.remove(profileID)
|
|
}
|
|
|
|
func togglePanel(
|
|
sessionKey: String,
|
|
agentID: String? = nil,
|
|
anchorProvider: @escaping () -> NSRect?)
|
|
{
|
|
let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID)
|
|
if let controller = panelController {
|
|
if !Self.shouldReuseController(currentRoute: self.panelRoute, requestedRoute: route) {
|
|
controller.close()
|
|
self.panelController = nil
|
|
self.panelRoute = nil
|
|
} else {
|
|
if controller.isVisible {
|
|
controller.close()
|
|
} else {
|
|
controller.presentAnchored(anchorProvider: anchorProvider)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
let controller = WebChatSwiftUIWindowController(
|
|
sessionKey: route.sessionKey,
|
|
agentID: route.agentID,
|
|
presentation: .panel(anchorProvider: anchorProvider))
|
|
controller.onClosed = { [weak self] in
|
|
self?.panelHidden()
|
|
}
|
|
controller.onVisibilityChanged = { [weak self] visible in
|
|
self?.onPanelVisibilityChanged?(visible)
|
|
}
|
|
controller.onSessionKeyChanged = { [weak self, weak controller] key in
|
|
guard let self, let controller, self.panelController === controller else { return }
|
|
let updatedRoute = (self.panelRoute ?? route).replacingSessionKey(key)
|
|
self.panelRoute = updatedRoute
|
|
self.currentChatRoute = updatedRoute
|
|
}
|
|
self.panelController = controller
|
|
self.panelRoute = route
|
|
self.currentChatRoute = route
|
|
controller.presentAnchored(anchorProvider: anchorProvider)
|
|
}
|
|
|
|
func recordActiveSessionKey(_ sessionKey: String) {
|
|
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
let route = self.currentChatRoute ?? self.panelRoute ?? self.windowRoute
|
|
self.currentChatRoute = route?.replacingSessionKey(trimmed)
|
|
?? WebChatRoute(sessionKey: trimmed, agentID: nil)
|
|
}
|
|
|
|
func closePanel() {
|
|
self.panelController?.close()
|
|
}
|
|
|
|
func preferredSessionKey() async -> String {
|
|
if let cachedPreferredSessionKey {
|
|
return cachedPreferredSessionKey
|
|
}
|
|
let key = await GatewayConnection.shared.mainSessionKey()
|
|
cachedPreferredSessionKey = key
|
|
return key
|
|
}
|
|
|
|
func resetTunnels() {
|
|
self.windowController?.close()
|
|
self.windowController = nil
|
|
self.windowRoute = nil
|
|
self.panelController?.close()
|
|
self.panelController = nil
|
|
self.panelRoute = nil
|
|
self.currentChatRoute = nil
|
|
self.cachedPreferredSessionKey = nil
|
|
let profileControllers = self.profileWindows.values.map(\.controller)
|
|
self.profileWindows.removeAll()
|
|
self.profileWindowOrder.removeAll()
|
|
self.unavailableProfileIDs.removeAll()
|
|
for controller in profileControllers {
|
|
controller.close()
|
|
}
|
|
Task { await MacGatewayConnectionFleet.shared.shutdown() }
|
|
}
|
|
|
|
func close() {
|
|
self.resetTunnels()
|
|
}
|
|
|
|
private func setSessionObserverVisible(
|
|
_ visible: Bool,
|
|
owner: WebChatSwiftUIWindowController,
|
|
connection: GatewayConnection)
|
|
{
|
|
let connectionID = ObjectIdentifier(connection)
|
|
guard let aggregateVisibility = self.sessionObserverOwners.setVisible(
|
|
visible,
|
|
owner: ObjectIdentifier(owner),
|
|
connection: connectionID)
|
|
else { return }
|
|
|
|
if aggregateVisibility, self.sessionObserverMonitors[connectionID] == nil {
|
|
// Visibility and subscriptions belong to a physical socket; a reconnect
|
|
// must redeclare both while any window on that connection remains open.
|
|
self.sessionObserverMonitors[connectionID] = Task { @MainActor [weak self] in
|
|
let pushes = await connection.subscribe(bufferingNewest: 1)
|
|
for await push in pushes {
|
|
guard !Task.isCancelled else { return }
|
|
guard case .snapshot = push else { continue }
|
|
guard let self else { return }
|
|
self.scheduleSessionObserverVisibility(
|
|
self.sessionObserverOwners.isVisible(connection: connectionID),
|
|
connection: connection)
|
|
}
|
|
}
|
|
}
|
|
self.scheduleSessionObserverVisibility(aggregateVisibility, connection: connection)
|
|
}
|
|
|
|
private func scheduleSessionObserverVisibility(
|
|
_ visible: Bool,
|
|
connection: GatewayConnection,
|
|
remainingHiddenRetries: Int = 1)
|
|
{
|
|
let connectionID = ObjectIdentifier(connection)
|
|
let previous = self.sessionObserverRequests[connectionID]?.task
|
|
let requestID = UUID()
|
|
let task = Task { @MainActor [weak self] in
|
|
await previous?.value
|
|
guard let self,
|
|
self.sessionObserverOwners.isVisible(connection: connectionID) == visible,
|
|
let lease = await connection.captureServerLease(),
|
|
self.sessionObserverOwners.isVisible(connection: connectionID) == visible
|
|
else {
|
|
self?.finishSessionObserverRequest(connection: connectionID, id: requestID)
|
|
return
|
|
}
|
|
|
|
if let declaration = self.sessionObserverDeclarations[connectionID],
|
|
declaration.visible == visible,
|
|
await connection.isCurrentServerLease(declaration.lease)
|
|
{
|
|
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
|
|
return
|
|
}
|
|
|
|
// A timed-out mutation may already have changed the Gateway. Clear
|
|
// the old confirmation before dispatch so reopening retries truthfully.
|
|
self.sessionObserverDeclarations.removeValue(forKey: connectionID)
|
|
do {
|
|
if visible {
|
|
let subscribe = OpenClawChatGatewayRequests.subscribeSessions()
|
|
_ = try await connection.request(
|
|
method: subscribe.method,
|
|
params: subscribe.params,
|
|
timeoutMs: subscribe.timeoutMs,
|
|
ifCurrentServerLease: lease)
|
|
}
|
|
guard self.sessionObserverOwners.isVisible(connection: connectionID) == visible else {
|
|
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
|
|
return
|
|
}
|
|
let request = OpenClawChatGatewayRequests.setSessionObserverVisibility(visible)
|
|
_ = try await connection.request(
|
|
method: request.method,
|
|
params: request.params,
|
|
timeoutMs: request.timeoutMs,
|
|
ifCurrentServerLease: lease)
|
|
if visible {
|
|
self.sessionObserverDeclarations[connectionID] = (lease: lease, visible: true)
|
|
} else {
|
|
self.sessionObserverDeclarations.removeValue(forKey: connectionID)
|
|
if !self.sessionObserverOwners.isVisible(connection: connectionID) {
|
|
self.sessionObserverMonitors.removeValue(forKey: connectionID)?.cancel()
|
|
}
|
|
}
|
|
} catch {
|
|
// A hidden mutation can time out after dispatch. Retry once on its
|
|
// original socket; keep the snapshot monitor for a replaced socket.
|
|
if !visible,
|
|
remainingHiddenRetries > 0,
|
|
await connection.isCurrentServerLease(lease),
|
|
!self.sessionObserverOwners.isVisible(connection: connectionID)
|
|
{
|
|
self.scheduleSessionObserverVisibility(
|
|
false,
|
|
connection: connection,
|
|
remainingHiddenRetries: remainingHiddenRetries - 1)
|
|
}
|
|
}
|
|
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
|
|
}
|
|
self.sessionObserverRequests[connectionID] = (id: requestID, task: task)
|
|
}
|
|
|
|
private func finishSessionObserverRequest(connection: ObjectIdentifier, id: UUID) {
|
|
guard self.sessionObserverRequests[connection]?.id == id else { return }
|
|
self.sessionObserverRequests.removeValue(forKey: connection)
|
|
}
|
|
|
|
private func panelHidden() {
|
|
self.onPanelVisibilityChanged?(false)
|
|
// Keep panel controller cached so reopening doesn't re-bootstrap.
|
|
}
|
|
|
|
static func shouldReuseController(
|
|
currentRoute: WebChatRoute?,
|
|
requestedRoute: WebChatRoute) -> Bool
|
|
{
|
|
currentRoute == requestedRoute
|
|
}
|
|
|
|
private enum GatewayProfileSelection {
|
|
case profile(MacGatewayProfile)
|
|
case manage
|
|
}
|
|
|
|
private static func promptForGatewayProfile(
|
|
profiles: [MacGatewayProfile],
|
|
preferredID: String?) -> GatewayProfileSelection?
|
|
{
|
|
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28), pullsDown: false)
|
|
popup.addItems(withTitles: profiles.map(Self.profilePickerTitle))
|
|
popup.selectItem(at: Self.preferredProfileIndex(profiles: profiles, preferredID: preferredID))
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = "New Gateway Window"
|
|
alert.informativeText = "Choose a saved Gateway. You can open more than one window for the same Gateway."
|
|
alert.accessoryView = popup
|
|
alert.addButton(withTitle: "Open Window")
|
|
alert.addButton(withTitle: "Manage Gateways…")
|
|
alert.addButton(withTitle: "Cancel")
|
|
switch alert.runModal() {
|
|
case .alertFirstButtonReturn:
|
|
guard profiles.indices.contains(popup.indexOfSelectedItem) else { return nil }
|
|
return .profile(profiles[popup.indexOfSelectedItem])
|
|
case .alertSecondButtonReturn:
|
|
return .manage
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
nonisolated static func preferredProfileIndex(profiles: [MacGatewayProfile], preferredID: String?) -> Int {
|
|
profiles.firstIndex { $0.id == preferredID } ?? 0
|
|
}
|
|
|
|
private static func profilePickerTitle(_ profile: MacGatewayProfile) -> String {
|
|
"\(profile.name) — \(profile.url.absoluteString)"
|
|
}
|
|
|
|
private static func showProfileError(_ error: Error, message: String) {
|
|
let alert = NSAlert(error: error)
|
|
alert.messageText = message
|
|
alert.runModal()
|
|
}
|
|
|
|
#if DEBUG
|
|
func _testSessionObserverVisible(connection: GatewayConnection) -> Bool {
|
|
self.sessionObserverOwners.isVisible(connection: ObjectIdentifier(connection))
|
|
}
|
|
|
|
func _testProfileWindowCount(profileID: String) -> Int {
|
|
self.profileWindows.values.count { $0.profileID == profileID }
|
|
}
|
|
#endif
|
|
}
|