Files
openclaw/apps/macos/Sources/OpenClaw/QuickChatModel.swift
Peter Steinberger 45f6543c9a feat(macos): Quick Chat power features — voice dictation, paste-to-app, model/reasoning control (#110994)
* feat(macos): add Quick Chat power features

* docs(macos): document Quick Chat power features

* chore(i18n): refresh native locale artifacts for Quick Chat power features

* fix(macos): update DEBUG test helper sendProvider to the reasoning-threaded arity
2026-07-18 17:21:03 -07:00

1243 lines
52 KiB
Swift

import Foundation
import Observation
import OpenClawChatUI
import OpenClawIPC
import OpenClawKit
import OpenClawProtocol
enum QuickChatAgentAvatar: Equatable, Sendable {
case none
case image(Data)
}
struct QuickChatAgentDisplay: Equatable, Sendable, Identifiable {
static let maximumAvatarBytes = 6_000_000
static let placeholder = QuickChatAgentDisplay(id: "", name: "Agent", emoji: nil, avatar: .none)
let id: String
let name: String
let emoji: String?
let avatar: QuickChatAgentAvatar
let monogram: String
let tintHue: Double
init(id: String, name: String, emoji: String?, avatar: QuickChatAgentAvatar = .none) {
self.id = id
self.name = name
self.emoji = emoji
self.avatar = avatar
let monogramSource = name.isEmpty ? id : name
self.monogram = String(monogramSource.prefix(1)).uppercased()
self.tintHue = Self.stableTintHue(for: id)
}
init(summary: AgentSummary) {
let emoji = (summary.identity?["emoji"]?.value as? String)?.nonEmptyTrimmed
let avatarRendered = summary.identity?["avatarUrl"]?.value as? String
self.init(
id: summary.id,
name: summary.name ?? summary.id,
emoji: emoji,
avatar: Self.avatar(fromRendered: avatarRendered))
}
static func avatar(fromRendered rawValue: String?) -> QuickChatAgentAvatar {
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty
else { return .none }
if rawValue.lowercased().hasPrefix("data:") {
guard let comma = rawValue.firstIndex(of: ",") else { return .none }
let metadata = rawValue[..<comma].lowercased()
guard metadata.hasSuffix(";base64") else { return .none }
let payload = rawValue[rawValue.index(after: comma)...]
let maximumEncodedBytes = ((Self.maximumAvatarBytes + 2) / 3) * 4 + 4
guard payload.utf8.count <= maximumEncodedBytes,
let data = Data(base64Encoded: String(payload), options: .ignoreUnknownCharacters),
data.count <= Self.maximumAvatarBytes
else { return .none }
return .image(data)
}
// Remote http(s) avatar URLs are deliberately not fetched: a gateway-supplied URL
// must not turn this app into a blind request primitive against local networks.
// Local avatar files already arrive as data URIs; everything else falls back.
return .none
}
private static func stableTintHue(for id: String) -> Double {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in id.utf8 {
hash ^= UInt64(byte)
hash &*= 1_099_511_628_211
}
return Double(hash % 360) / 360
}
}
struct QuickChatRoutingTarget: Equatable, Hashable, Sendable {
let sessionKey: String
let agentID: String?
}
struct QuickChatSessionTargetOverride: Equatable, Hashable, Sendable {
let key: String
let displayName: String
}
enum QuickChatConnectionGate: Equatable {
case available
case unconfigured
case paused
case disconnected
}
enum QuickChatSendState: Equatable {
case idle
case sending
case sent
case failed(String)
}
@MainActor
@Observable
final class QuickChatModel {
private struct RetryIdentity {
let draft: String
let message: String
let thinking: String?
let sessionKey: String
let agentID: String?
let attachments: [OpenClawChatAttachmentPayload]
let idempotencyKey: String
}
typealias SessionKeyProvider = @MainActor () async -> String
typealias AgentsProvider = @MainActor () async throws -> AgentsListResult
typealias AgentIdentityProvider = @MainActor (String) async throws -> QuickChatAgentDisplay
typealias SendProvider = @MainActor (
String,
String?,
String,
String?,
String,
[OpenClawChatAttachmentPayload]) async throws -> String
typealias PermissionStatusProvider = @MainActor ([Capability]) async -> [Capability: Bool]
typealias PermissionGrantProvider = @MainActor ([Capability]) async -> [Capability: Bool]
typealias ConnectionGateProvider = @MainActor () -> QuickChatConnectionGate
typealias FrontmostAppNameProvider = @MainActor () -> String
typealias TextContextCaptureProvider = @MainActor () async -> QuickChatTextContextCaptureOutcome
typealias ModelControlsProvider = @MainActor (
QuickChatRoutingTarget) async throws -> QuickChatModelControlSnapshot
typealias ModelPatchProvider = @MainActor (
QuickChatRoutingTarget,
String?) async throws -> OpenClawChatModelPatchResult?
static let trackedPermissions: [Capability] = [.notifications, .accessibility, .screenRecording]
var text = "" {
didSet {
if !self.text.isEmpty, self.sendState == .sent {
self.sendState = .idle
}
if self.sendTask == nil, let retryIdentity = self.retryIdentity, retryIdentity.draft != self.text {
self.retryIdentity = nil
}
if !self.isDictating, !self.isStartingDictation, self.dictationTextSession == nil {
self.dictationSelectionRange = nil
}
}
}
private(set) var sessionKey = ""
private(set) var sendAgentID: String?
private(set) var targetSessionOverride: QuickChatSessionTargetOverride?
private(set) var agents: [QuickChatAgentDisplay] = []
private(set) var defaultAgentID: String?
private(set) var selectedAgentID: String?
private(set) var agentDisplay = QuickChatAgentDisplay.placeholder
private(set) var missingPermissions: [Capability] = []
private(set) var permissionsDismissedThisSession = false
private(set) var isGrantingPermissions = false
private(set) var sendState: QuickChatSendState = .idle
private(set) var isPresentationActive = false
private(set) var frontmostAppName = String(localized: "focused app")
private(set) var textContext: QuickChatTextContext?
private(set) var isCapturingTextContext = false
private(set) var textContextCaptureMessage: String?
private(set) var isStartingDictation = false
private(set) var isDictating = false
private(set) var dictationStatusMessage: String?
private(set) var dictationSelectionRange: NSRange?
private(set) var modelChoices: [OpenClawChatModelChoice] = []
private(set) var currentSessionModelSelectionID: String?
private(set) var currentSessionThinkingLevel: String?
private(set) var thinkingOptions = QuickChatModelControlLogic.baseThinkingOptions
private(set) var selectedModelSelectionID: String?
private(set) var selectedThinkingLevel: String?
private(set) var modelDefaultProvider: String?
private(set) var isLoadingModelControls = false
private(set) var isUpdatingModel = false
private(set) var modelControlStatusMessage: String?
/// Route of the most recently accepted send; navigation reads this immutable value
/// instead of sampling live routing state that an agent switch could move meanwhile.
private(set) var lastAcceptedRoute: QuickChatRoutingTarget?
private(set) var lastAcceptedIdempotencyKey: String?
@ObservationIgnored private let sessionKeyProvider: SessionKeyProvider
@ObservationIgnored private let agentsProvider: AgentsProvider
@ObservationIgnored private let agentIdentityProvider: AgentIdentityProvider
@ObservationIgnored private let sendProvider: SendProvider
@ObservationIgnored private let permissionStatusProvider: PermissionStatusProvider
@ObservationIgnored private let permissionGrantProvider: PermissionGrantProvider
@ObservationIgnored private let connectionGateProvider: ConnectionGateProvider
@ObservationIgnored private let frontmostAppNameProvider: FrontmostAppNameProvider
@ObservationIgnored private let textContextCaptureProvider: TextContextCaptureProvider
@ObservationIgnored private let modelControlsProvider: ModelControlsProvider
@ObservationIgnored private let modelPatchProvider: ModelPatchProvider
/// Invoked with the snapshotted route just before a send is dispatched, for every
/// send path (text and capture); wires the reply consumer's pre-bind.
@ObservationIgnored var onSendDispatched: ((QuickChatRoutingTarget) -> Void)?
@ObservationIgnored private var presentationID = UUID()
@ObservationIgnored private var agentsScope: String?
@ObservationIgnored private var agentsMainKey: String?
@ObservationIgnored private var baseRoutingTarget: QuickChatRoutingTarget?
@ObservationIgnored private var sendTask: Task<String, Error>?
@ObservationIgnored private var permissionTask: Task<Void, Never>?
@ObservationIgnored private var permissionPollTask: Task<Void, Never>?
@ObservationIgnored private var retryIdentity: RetryIdentity?
@ObservationIgnored private var capturePipelineID: UUID?
@ObservationIgnored private var textContextCaptureID: UUID?
@ObservationIgnored private var textContextCaptureTask: Task<Void, Never>?
@ObservationIgnored private var dictationTextSession: QuickChatDictationTextSession?
@ObservationIgnored private var modelControlsTask: Task<Void, Never>?
@ObservationIgnored private var modelControlsRequestID = UUID()
@ObservationIgnored private var modelPatchTask: Task<OpenClawChatModelPatchResult?, Error>?
@ObservationIgnored private var modelPatchTarget: QuickChatRoutingTarget?
@ObservationIgnored private var modelPatchSelectionID: String?
@ObservationIgnored private var modelPatchRequestID = UUID()
@ObservationIgnored private var appliedModelSelections: [QuickChatRoutingTarget: String] = [:]
init(
sessionKeyProvider: @escaping SessionKeyProvider = {
await GatewayConnection.shared.mainSessionKey()
},
agentsProvider: @escaping AgentsProvider = {
try await GatewayConnection.shared.agentsList()
},
agentIdentityProvider: @escaping AgentIdentityProvider = { sessionKey in
let identity = try await GatewayConnection.shared.agentIdentity(sessionKey: sessionKey)
let name = identity.name?.nonEmptyTrimmed ?? identity.agentid
let emoji = identity.emoji?.nonEmptyTrimmed
return QuickChatAgentDisplay(
id: identity.agentid,
name: name,
emoji: emoji,
avatar: QuickChatAgentDisplay.avatar(fromRendered: identity.avatar))
},
sendProvider: @escaping SendProvider = { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
let response = try await GatewayConnection.shared.chatSend(
sessionKey: sessionKey,
agentID: agentID,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
return response.status
},
permissionStatusProvider: @escaping PermissionStatusProvider = { capabilities in
await PermissionManager.status(capabilities)
},
permissionGrantProvider: @escaping PermissionGrantProvider = { capabilities in
await PermissionManager.ensure(capabilities, interactive: true)
},
connectionGateProvider: @escaping ConnectionGateProvider = {
let appState = AppStateStore.shared
if appState.connectionMode == .unconfigured { return .unconfigured }
if appState.isPaused { return .paused }
if ControlChannel.shared.state != .connected { return .disconnected }
return .available
},
frontmostAppNameProvider: @escaping FrontmostAppNameProvider = {
QuickChatFocusedTextCaptureService.frontmostApplicationName()
},
textContextCaptureProvider: @escaping TextContextCaptureProvider = {
await QuickChatFocusedTextCaptureService.capture()
},
modelControlsProvider: @escaping ModelControlsProvider = { target in
let transport = MacGatewayChatTransport(defaultGlobalAgentID: target.agentID)
async let models = transport.listModels()
async let sessions = transport.listSessions(limit: 200, search: target.sessionKey, archived: false)
async let agents = GatewayConnection.shared.agentsList()
return try await QuickChatModelControlLogic.snapshot(
target: target,
models: models,
sessions: sessions,
agents: agents)
},
modelPatchProvider: @escaping ModelPatchProvider = { target, model in
let transport = MacGatewayChatTransport(defaultGlobalAgentID: target.agentID)
return try await transport.patchSessionSettings(
sessionKey: target.sessionKey,
agentID: target.agentID,
patch: OpenClawChatSessionSettingsPatch(model: .some(model)))
})
{
self.sessionKeyProvider = sessionKeyProvider
self.agentsProvider = agentsProvider
self.agentIdentityProvider = agentIdentityProvider
self.sendProvider = sendProvider
self.permissionStatusProvider = permissionStatusProvider
self.permissionGrantProvider = permissionGrantProvider
self.connectionGateProvider = connectionGateProvider
self.frontmostAppNameProvider = frontmostAppNameProvider
self.textContextCaptureProvider = textContextCaptureProvider
self.modelControlsProvider = modelControlsProvider
self.modelPatchProvider = modelPatchProvider
}
var connectionGate: QuickChatConnectionGate {
self.connectionGateProvider()
}
var connectionStatusMessage: String? {
switch self.connectionGate {
case .available: nil
case .unconfigured: "Not configured"
case .paused: "OpenClaw is paused"
case .disconnected: "Gateway disconnected"
}
}
var shouldShowPermissionStrip: Bool {
!self.permissionsDismissedThisSession && !self.missingPermissions.isEmpty
}
var canSend: Bool {
let hasPendingOverrideState = self.selectedModelSelectionID != nil || self.selectedThinkingLevel != nil
return (!self.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.textContext != nil) &&
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isCapturingTextContext &&
!self.isStartingDictation &&
!self.isUpdatingModel &&
(!hasPendingOverrideState || !self.isLoadingModelControls) &&
self.isSelectedThinkingLevelSupported &&
self.sendState != .sending
}
var canCaptureWindow: Bool {
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
!self.isStartingDictation &&
!self.isDictating &&
!self.isUpdatingModel &&
self.sendState != .sending
}
var canCaptureTextContext: Bool {
self.canCaptureWindow && !self.isCapturingTextContext
}
var canSelectRecentSession: Bool {
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
!self.isStartingDictation &&
!self.isDictating &&
!self.isUpdatingModel &&
self.sendState != .sending
}
var canToggleDictation: Bool {
self.isDictating || self.isStartingDictation || (
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
!self.isUpdatingModel &&
self.sendState != .sending)
}
var messagePlaceholder: String {
if let targetSessionOverride {
return "Reply in \(targetSessionOverride.displayName)"
}
return "Message \(self.agentDisplay.name)"
}
var routingTarget: QuickChatRoutingTarget? {
guard !self.sessionKey.isEmpty else { return nil }
return QuickChatRoutingTarget(sessionKey: self.sessionKey, agentID: self.sendAgentID)
}
var activePresentationID: UUID? {
self.isPresentationActive ? self.presentationID : nil
}
func beginPresentation() -> UUID {
self.presentationID = UUID()
self.isPresentationActive = true
self.sessionKey = ""
self.sendAgentID = nil
self.frontmostAppName = self.frontmostAppNameProvider()
self.textContext = nil
self.textContextCaptureMessage = nil
self.cancelTextContextCapture()
self.resetDictationState()
self.cancelModelControlRefresh()
self.appliedModelSelections.removeAll()
self.selectedModelSelectionID = nil
self.selectedThinkingLevel = nil
self.currentSessionModelSelectionID = nil
self.currentSessionThinkingLevel = nil
self.modelControlStatusMessage = nil
self.targetSessionOverride = nil
self.baseRoutingTarget = nil
// The cached list stays displayable, but routing metadata must wait for the fresh
// contract: selecting from a stale scope/mainKey could target an obsolete session.
self.agentsScope = nil
self.agentsMainKey = nil
if self.sendTask == nil { self.sendState = .idle }
self.startPermissionPolling(id: self.presentationID)
return self.presentationID
}
func refreshForPresentation(id: UUID) async {
guard self.isCurrentPresentation(id) else { return }
async let permissionStatus = self.permissionStatusProvider(Self.trackedPermissions)
let agentsResult: Result<AgentsListResult, Error>
do {
agentsResult = try await .success(self.agentsProvider())
} catch {
agentsResult = .failure(error)
}
let status = await permissionStatus
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
self.applyPermissionStatus(status)
switch agentsResult {
case let .success(result):
self.applyAgentsList(result)
case .failure:
await self.refreshFallbackIdentity(id: id)
}
}
func selectAgent(_ id: String) {
// A held or in-flight send is already routed; switching now would reroute a
// screenshot captured for the previously selected agent.
guard self.sendState != .sending,
!self.isUpdatingModel,
let display = self.agents.first(where: { $0.id == id }),
let mainKey = self.agentsMainKey
else { return }
// Agent and recent-session targeting are mutually exclusive: keeping both would
// make the avatar advertise one destination while sends go somewhere else.
self.targetSessionOverride = nil
self.selectedAgentID = id
self.agentDisplay = display
let target = Self.routingTarget(scope: self.agentsScope, selectedAgentID: id, mainKey: mainKey)
self.baseRoutingTarget = target
self.applyRoutingTarget()
}
func selectSessionOverride(_ target: QuickChatSessionTargetOverride?) {
guard self.sendState != .sending, !self.isUpdatingModel else { return }
self.targetSessionOverride = target
self.applyRoutingTarget()
}
func dismissPermissionsForSession() {
self.permissionsDismissedThisSession = true
}
func grantMissingPermissions() {
let capabilities = self.missingPermissions
guard !capabilities.isEmpty, !self.isGrantingPermissions else { return }
let id = self.presentationID
self.permissionTask?.cancel()
self.isGrantingPermissions = true
self.permissionTask = Task { [weak self] in
guard let self else { return }
_ = await self.permissionGrantProvider(capabilities)
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
await self.recheckPermissions(id: id)
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
self.isGrantingPermissions = false
self.permissionTask = nil
}
}
func prepareDictation(selection: NSRange) -> Bool {
guard self.canToggleDictation, !self.isDictating, !self.isStartingDictation else { return false }
self.dictationTextSession = QuickChatDictationTextSession(
baseText: self.text,
replacementRange: selection)
self.dictationSelectionRange = nil
self.dictationStatusMessage = nil
self.isStartingDictation = true
return true
}
func dictationDidStart() {
guard self.isStartingDictation else { return }
self.isStartingDictation = false
self.isDictating = true
}
func applyDictationTranscript(_ transcript: String) {
guard self.isDictating, let dictationTextSession else { return }
let update = dictationTextSession.update(transcript: transcript)
self.dictationSelectionRange = update.selection
self.text = update.text
}
func stopDictation() {
self.isStartingDictation = false
self.isDictating = false
self.dictationTextSession = nil
}
func failDictation(message: String) {
self.stopDictation()
self.dictationStatusMessage = message
}
func selectThinkingLevel(_ level: String?) {
let normalized = level?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let selection = normalized?.isEmpty == false ? normalized : nil
guard selection != self.selectedThinkingLevel else { return }
self.selectedThinkingLevel = selection
self.modelControlStatusMessage = nil
self.retryIdentity = nil
}
func selectModel(_ selectionID: String) {
guard self.canUseModelControls, !self.isUpdatingModel, let target = self.routingTarget else { return }
let normalized = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized == OpenClawChatViewModel.defaultModelSelectionID ||
self.modelChoices.contains(where: { $0.selectionID == normalized })
else { return }
guard normalized != self.selectedModelSelectionID else { return }
let patchDecision = QuickChatModelControlLogic.modelPatchDecision(
selectionID: normalized,
appliedSelectionID: self.appliedModelSelections[target],
currentSessionSelectionID: self.currentSessionModelSelectionID)
self.selectedModelSelectionID = normalized
self.modelControlStatusMessage = nil
if patchDecision != .none {
self.retryIdentity = nil
}
self.startSelectedModelPatchIfNeeded(selectionID: normalized, target: target)
}
func send() async -> Bool {
await self.performSend(messageOverride: nil, attachments: [])
}
func captureFocusedAppText() {
guard self.canCaptureTextContext, self.isPresentationActive else { return }
let captureID = UUID()
let presentationID = self.presentationID
self.textContextCaptureID = captureID
self.isCapturingTextContext = true
self.textContextCaptureMessage = nil
self.textContextCaptureTask?.cancel()
self.textContextCaptureTask = Task { [weak self] in
guard let self else { return }
let outcome = await self.textContextCaptureProvider()
guard self.isCurrentPresentation(presentationID),
self.textContextCaptureID == captureID,
!Task.isCancelled
else { return }
self.textContextCaptureID = nil
self.textContextCaptureTask = nil
self.isCapturingTextContext = false
switch outcome {
case let .captured(context):
self.replaceTextContext(context)
case let .failed(message):
self.textContextCaptureMessage = message
case .cancelled:
break
}
}
}
func replaceTextContext(_ context: QuickChatTextContext) {
self.textContext = context
self.textContextCaptureMessage = nil
self.retryIdentity = nil
}
func clearTextContext() {
self.textContext = nil
self.retryIdentity = nil
}
/// Holds the send state for the whole capture pipeline so typing/Return cannot race
/// the screenshot send. Returns an ownership token (nil when a send is already active);
/// detached processing can outlive cancellation, so every later pipeline mutation must
/// present this token or it could reset a newer pipeline's held state.
func beginCapturePipeline() -> UUID? {
guard self.sendState != .sending else { return nil }
let id = UUID()
self.capturePipelineID = id
self.sendState = .sending
return id
}
func cancelCapturePipeline(_ id: UUID) {
guard self.capturePipelineID == id else { return }
self.capturePipelineID = nil
if self.sendState == .sending, self.sendTask == nil {
self.sendState = .idle
}
}
func failCapturePipeline(_ id: UUID) {
guard self.capturePipelineID == id else { return }
self.capturePipelineID = nil
self.sendState = .failed(String(localized: "Couldn't capture that image."))
}
/// Pre-pipeline capture failures (enumeration, no candidates). Never clobbers a held
/// send state; those failures belong to their owning pipeline token.
func setCaptureFailure() {
guard self.sendState != .sending else { return }
self.sendState = .failed(String(localized: "Couldn't capture that image."))
}
func sendCapturedImage(
pipelineID: UUID,
data: Data,
label: String,
fileName: String) async -> Bool
{
// Bind the pipeline to the route visible when the user completed the selection; an agent
// switch or re-presentation during processing must drop the capture, not reroute it.
guard self.capturePipelineID == pipelineID,
let presentationID = self.activePresentationID
else { return false }
let sessionKey = self.sessionKey
let agentID = self.sendAgentID
guard !sessionKey.isEmpty else {
self.cancelCapturePipeline(pipelineID)
return false
}
let draft = self.text
let trimmedDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines)
let baseMessage = trimmedDraft.isEmpty ? String(localized: "Screenshot: \(label)") : trimmedDraft
let textContext = self.textContext
let message = Self.assembleMessage(draft: baseMessage, context: textContext)
let attachment: OpenClawChatAttachmentPayload
do {
attachment = try await Task.detached(priority: .userInitiated) {
let processed = try ChatImageProcessor.processForUpload(data: data)
return OpenClawChatAttachmentPayload(
type: "file",
mimeType: "image/jpeg",
fileName: fileName,
content: processed.base64EncodedString())
}.value
} catch {
self.failCapturePipeline(pipelineID)
return false
}
guard self.capturePipelineID == pipelineID,
self.activePresentationID == presentationID,
self.sessionKey == sessionKey,
self.sendAgentID == agentID,
!Task.isCancelled
else {
self.cancelCapturePipeline(pipelineID)
return false
}
let accepted = await self.performSend(
messageOverride: message,
attachments: [attachment],
draftOverride: draft,
textContextOverride: textContext,
continuesCapturePipeline: true)
if self.capturePipelineID == pipelineID {
self.capturePipelineID = nil
// performSend left the held state untouched only on its early guard failure.
if !accepted, self.sendState == .sending, self.sendTask == nil {
self.sendState = .idle
}
}
return accepted
}
nonisolated static func routingTarget(
scope: String?,
selectedAgentID: String,
mainKey: String) -> QuickChatRoutingTarget
{
if scope?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "global" {
return QuickChatRoutingTarget(sessionKey: "global", agentID: selectedAgentID)
}
// Canonical agent keys already encode ownership; a redundant agentId is rejected.
return QuickChatRoutingTarget(
sessionKey: "agent:\(selectedAgentID):\(mainKey)",
agentID: nil)
}
nonisolated static func assembleMessage(draft: String, context: QuickChatTextContext?) -> String {
let trimmedDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard let context else { return trimmedDraft }
let contextLabel = String(localized: "Context from \(context.appName)\(context.windowTitle)")
let contextBlock = "[\(contextLabel)]\n\(context.text)"
return trimmedDraft.isEmpty ? contextBlock : "\(trimmedDraft)\n\n\(contextBlock)"
}
nonisolated static func routingTarget(
override: QuickChatSessionTargetOverride?,
base: QuickChatRoutingTarget) -> QuickChatRoutingTarget
{
guard let override else { return base }
// sessions.list preserves the bare global sentinel. It needs the same explicit
// agent owner as the selected base route when session scope is global.
let agentID = override.key.lowercased() == "global" ? base.agentID : nil
return QuickChatRoutingTarget(sessionKey: override.key, agentID: agentID)
}
nonisolated static func screenshotFileName(appName: String) -> String {
let scalarValues = appName.lowercased().unicodeScalars.map { scalar -> Character in
CharacterSet.alphanumerics.contains(scalar) ? Character(String(scalar)) : "-"
}
let sanitized = String(scalarValues)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
return "window-\(sanitized.isEmpty ? "screenshot" : sanitized).jpg"
}
func endPresentation() {
self.isPresentationActive = false
self.presentationID = UUID()
// A quick bar target is presentation-scoped. Never carry a stale recent session
// into the next invocation where the compact UI no longer explains that choice.
self.targetSessionOverride = nil
self.baseRoutingTarget = nil
self.sessionKey = ""
self.sendAgentID = nil
// Captured AX text is presentation-scoped: hiding the bar must erase it so a
// later recipient can never inherit stale focused-app context.
self.clearTextContext()
self.textContextCaptureMessage = nil
self.cancelTextContextCapture()
self.resetDictationState()
// Model patches are persistent session mutations. Let an accepted selection
// settle even though its presentation-scoped controls are being discarded.
self.cancelModelControlRefresh()
self.appliedModelSelections.removeAll()
self.selectedModelSelectionID = nil
self.selectedThinkingLevel = nil
self.currentSessionModelSelectionID = nil
self.currentSessionThinkingLevel = nil
self.modelControlStatusMessage = nil
// A dispatched chat.send may already be accepted; cancelling and retrying with a new UUID can duplicate it.
self.cancelPermissionTask()
self.cancelPermissionPolling()
}
func cancelAllTasks() {
self.sendTask?.cancel()
self.sendTask = nil
self.retryIdentity = nil
self.cancelPermissionTask()
self.cancelPermissionPolling()
self.cancelTextContextCapture()
self.cancelModelControlRefresh()
self.resetDictationState()
if self.sendState == .sending { self.sendState = .idle }
}
private func applyAgentsList(_ result: AgentsListResult) {
let displays = result.agents.map(QuickChatAgentDisplay.init(summary:))
let selectedID: String? = if let selectedAgentID,
displays.contains(where: { $0.id == selectedAgentID })
{
selectedAgentID
} else if displays.contains(where: { $0.id == result.defaultid }) {
result.defaultid
} else {
displays.first?.id
}
self.agents = displays
self.defaultAgentID = result.defaultid
self.selectedAgentID = selectedID
self.agentsScope = result.scope.value as? String
self.agentsMainKey = result.mainkey
guard let selectedID,
let display = displays.first(where: { $0.id == selectedID })
else {
self.agentDisplay = .placeholder
self.baseRoutingTarget = nil
self.sessionKey = ""
self.sendAgentID = nil
return
}
self.agentDisplay = display
let target = Self.routingTarget(
scope: self.agentsScope,
selectedAgentID: selectedID,
mainKey: result.mainkey)
self.baseRoutingTarget = target
self.applyRoutingTarget()
}
private func refreshFallbackIdentity(id: UUID) async {
let resolvedSessionKey = await self.sessionKeyProvider()
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
self.baseRoutingTarget = QuickChatRoutingTarget(sessionKey: resolvedSessionKey, agentID: nil)
self.applyRoutingTarget()
self.agents = []
self.defaultAgentID = nil
self.selectedAgentID = nil
self.agentsScope = nil
self.agentsMainKey = nil
self.agentDisplay = .placeholder
do {
let display = try await self.agentIdentityProvider(resolvedSessionKey)
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
self.agentDisplay = display
self.agents = [display]
self.defaultAgentID = display.id
self.selectedAgentID = display.id
} catch {
// The fallback session remains sendable even when its optional identity cannot load.
}
}
private func applyRoutingTarget() {
guard let baseRoutingTarget else {
self.sessionKey = ""
self.sendAgentID = nil
self.cancelModelControlRefresh()
return
}
let target = Self.routingTarget(override: self.targetSessionOverride, base: baseRoutingTarget)
let previousTarget = self.routingTarget
self.sessionKey = target.sessionKey
self.sendAgentID = target.agentID
if previousTarget != target {
// Explicit overrides survive target changes, but the target's underlying
// session state must be read again before the compact control claims a value.
self.currentSessionModelSelectionID = nil
self.currentSessionThinkingLevel = nil
self.modelDefaultProvider = nil
self.thinkingOptions = []
}
self.refreshModelControls(for: target)
}
private func performSend(
messageOverride: String?,
attachments: [OpenClawChatAttachmentPayload],
draftOverride: String? = nil,
textContextOverride: QuickChatTextContext? = nil,
continuesCapturePipeline: Bool = false) async -> Bool
{
guard let presentationID = self.activePresentationID else { return false }
// The capture pipeline captured its draft before detached processing; edits made
// meanwhile must survive, so the clear-decision compares against that original.
let draft = draftOverride ?? self.text
let textContext = textContextOverride ?? self.textContext
let message = messageOverride ?? Self.assembleMessage(draft: draft, context: textContext)
guard !message.isEmpty, !self.sessionKey.isEmpty, self.connectionGate == .available else {
// The capture pipeline unwinds its own held state after this returns false.
return false
}
guard continuesCapturePipeline || self.sendState != .sending else { return false }
let sessionKey = self.sessionKey
let agentID = self.sendAgentID
let route = QuickChatRoutingTarget(sessionKey: sessionKey, agentID: agentID)
let selectedModelSelectionID = self.selectedModelSelectionID
guard await self.applySelectedModelIfNeeded(
to: route,
selectionID: selectedModelSelectionID)
else { return false }
guard !Task.isCancelled,
self.isCurrentPresentation(presentationID),
self.routingTarget == route,
self.connectionGate == .available,
self.isSelectedThinkingLevelSupported,
continuesCapturePipeline || self.sendState != .sending
else { return false }
let thinking = self.selectedThinkingLevel
self.lastAcceptedIdempotencyKey = nil
// Pre-bind the reply consumer for every send path (text and screenshots): a
// fast turn must not emit frames before the reply view model starts listening.
self.onSendDispatched?(route)
let idempotencyKey: String
if let retryIdentity = self.retryIdentity,
retryIdentity.draft == draft,
retryIdentity.message == message,
retryIdentity.thinking == thinking,
retryIdentity.sessionKey == sessionKey,
retryIdentity.agentID == agentID,
retryIdentity.attachments == attachments
{
idempotencyKey = retryIdentity.idempotencyKey
} else {
idempotencyKey = UUID().uuidString
self.retryIdentity = RetryIdentity(
draft: draft,
message: message,
thinking: thinking,
sessionKey: sessionKey,
agentID: agentID,
attachments: attachments,
idempotencyKey: idempotencyKey)
}
let task = Task {
try await self.sendProvider(sessionKey, agentID, message, thinking, idempotencyKey, attachments)
}
self.sendTask = task
self.sendState = .sending
do {
let status = try await task.value
self.sendTask = nil
switch ChatSendStatus.acceptance(of: status) {
case .terminalFailure:
self.retryIdentity = nil
let normalized = ChatSendStatus.normalized(status)
self.sendState = self.text == draft
? .failed("Message was not accepted (\(normalized)).")
: .idle
return false
case .terminalSuccess, .inFlight:
self.retryIdentity = nil
self.lastAcceptedRoute = route
self.lastAcceptedIdempotencyKey = idempotencyKey
if self.textContext == textContext {
self.textContext = nil
}
if self.text == draft {
self.sendState = .sent
self.text = ""
} else {
self.sendState = .idle
}
return true
}
} catch is CancellationError {
self.sendTask = nil
self.retryIdentity = nil
self.sendState = .idle
return false
} catch {
self.sendTask = nil
self.sendState = self.text == draft ? .failed(error.localizedDescription) : .idle
return false
}
}
private func startPermissionPolling(id: UUID) {
guard !ProcessInfo.processInfo.isRunningTests else { return }
self.permissionPollTask?.cancel()
// TCC posts no change notifications; poll only while the bar is presented.
self.permissionPollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard let self, self.isCurrentPresentation(id), !Task.isCancelled else { return }
await self.recheckPermissions(id: id)
}
}
}
private func cancelPermissionPolling() {
self.permissionPollTask?.cancel()
self.permissionPollTask = nil
}
private func cancelPermissionTask() {
self.permissionTask?.cancel()
self.permissionTask = nil
self.isGrantingPermissions = false
}
private func cancelTextContextCapture() {
self.textContextCaptureTask?.cancel()
self.textContextCaptureTask = nil
self.textContextCaptureID = nil
self.isCapturingTextContext = false
}
private func recheckPermissions(id: UUID) async {
let status = await self.permissionStatusProvider(Self.trackedPermissions)
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
self.applyPermissionStatus(status)
}
private func applyPermissionStatus(_ status: [Capability: Bool]) {
self.missingPermissions = Self.trackedPermissions.filter { status[$0] != true }
}
private func isCurrentPresentation(_ id: UUID) -> Bool {
self.isPresentationActive && self.presentationID == id
}
}
extension QuickChatModel {
var canUseModelControls: Bool {
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
!self.isStartingDictation &&
!self.isLoadingModelControls &&
!self.isUpdatingModel &&
self.sendState != .sending
}
var isSelectedThinkingLevelSupported: Bool {
QuickChatModelControlLogic.validatedThinkingSelection(
self.selectedThinkingLevel,
options: self.thinkingOptions) == self.selectedThinkingLevel
}
var modelPickerSections: ChatModelPickerSections {
ChatModelPickerStore.sections(
choices: self.modelChoices,
favorites: [],
recents: [],
defaultProvider: self.modelDefaultProvider)
}
var displayedModelSelectionID: String? {
self.selectedModelSelectionID ?? self.currentSessionModelSelectionID
}
var displayedThinkingLevel: String? {
self.selectedThinkingLevel ?? self.currentSessionThinkingLevel
}
var modelControlLabel: String {
let automatic = String(localized: "Auto")
let model = QuickChatModelControlLogic.displayName(
selectionID: self.displayedModelSelectionID,
models: self.modelChoices,
automaticLabel: automatic)
let thinking = self.thinkingOptions.first(where: { $0.id == self.displayedThinkingLevel })?.label
?? self.displayedThinkingLevel
?? automatic
return String(localized: "\(model) · \(thinking)")
}
private func refreshModelControls(for target: QuickChatRoutingTarget) {
guard self.isPresentationActive else { return }
let requestID = UUID()
self.modelControlsRequestID = requestID
self.modelControlsTask?.cancel()
self.isLoadingModelControls = true
self.modelControlsTask = Task { [weak self] in
guard let self else { return }
do {
let snapshot = try await self.modelControlsProvider(target)
guard !Task.isCancelled,
self.modelControlsRequestID == requestID,
self.routingTarget == target
else { return }
self.modelChoices = snapshot.models
self.currentSessionModelSelectionID = snapshot.currentModelSelectionID
self.currentSessionThinkingLevel = snapshot.currentThinkingLevel
self.thinkingOptions = snapshot.thinkingOptions
self.modelDefaultProvider = snapshot.defaultProvider
self.modelControlStatusMessage = self.isSelectedThinkingLevelSupported
? nil
: String(localized: "Selected reasoning is unavailable for this target.")
} catch is CancellationError {
return
} catch {
guard self.modelControlsRequestID == requestID,
self.routingTarget == target
else { return }
self.modelControlStatusMessage = String(localized: "Couldn't load model settings.")
}
guard self.modelControlsRequestID == requestID else { return }
self.isLoadingModelControls = false
self.modelControlsTask = nil
}
}
private func startSelectedModelPatchIfNeeded(selectionID: String, target: QuickChatRoutingTarget) {
Task { [weak self] in
_ = await self?.applySelectedModelIfNeeded(to: target, selectionID: selectionID)
}
}
private func applySelectedModelIfNeeded(
to target: QuickChatRoutingTarget,
selectionID: String?) async -> Bool
{
let appliedSelectionID = self.appliedModelSelections[target]
switch QuickChatModelControlLogic.modelPatchDecision(
selectionID: selectionID,
appliedSelectionID: appliedSelectionID,
currentSessionSelectionID: self.routingTarget == target
? self.currentSessionModelSelectionID
: nil)
{
case .none:
return true
case let .patch(model):
// Model is a session setting and persists through sessions.patch. Reasoning
// remains a per-message override passed only to chat.send below.
if let task = self.modelPatchTask,
self.modelPatchTarget == target,
self.modelPatchSelectionID == selectionID
{
return await self.finishModelPatch(
task,
requestID: self.modelPatchRequestID,
target: target,
selectionID: selectionID)
}
self.modelPatchTask?.cancel()
self.modelControlsRequestID = UUID()
self.modelControlsTask?.cancel()
self.modelControlsTask = nil
self.isLoadingModelControls = false
let requestID = UUID()
self.modelPatchRequestID = requestID
self.modelPatchTarget = target
self.modelPatchSelectionID = selectionID
self.isUpdatingModel = true
let task = Task {
try await self.modelPatchProvider(target, model)
}
self.modelPatchTask = task
return await self.finishModelPatch(
task,
requestID: requestID,
target: target,
selectionID: selectionID)
}
}
private func finishModelPatch(
_ task: Task<OpenClawChatModelPatchResult?, Error>,
requestID: UUID,
target: QuickChatRoutingTarget,
selectionID: String?) async -> Bool
{
do {
let result = try await task.value
var refreshedSnapshot: QuickChatModelControlSnapshot?
var refreshFailed = false
if result?.thinkingLevels == nil, self.routingTarget == target {
do {
refreshedSnapshot = try await self.modelControlsProvider(target)
} catch {
refreshFailed = true
}
}
guard self.modelPatchRequestID == requestID else { return false }
if self.isPresentationActive, let selectionID {
self.appliedModelSelections[target] = selectionID
}
if self.routingTarget == target {
self.modelControlsRequestID = UUID()
self.modelControlsTask?.cancel()
self.modelControlsTask = nil
self.isLoadingModelControls = false
if let refreshedSnapshot {
self.modelChoices = refreshedSnapshot.models
self.currentSessionModelSelectionID = refreshedSnapshot.currentModelSelectionID
self.currentSessionThinkingLevel = refreshedSnapshot.currentThinkingLevel
self.modelDefaultProvider = refreshedSnapshot.defaultProvider
} else if let model = result?.model {
self.currentSessionModelSelectionID = QuickChatModelControlLogic.selectionID(
model: model,
provider: result?.modelProvider)
} else if selectionID != OpenClawChatViewModel.defaultModelSelectionID {
self.currentSessionModelSelectionID = selectionID
} else {
self.currentSessionModelSelectionID = nil
}
// The refreshedSnapshot branch above already seeded currentSessionThinkingLevel
// from the authoritative fetch, so when the patch response omits both
// thinkingLevels and thinkingLevel both branches here are skipped and the
// refreshed level survives; these only override when the patch itself
// returned a level.
if result?.thinkingLevels != nil {
self.currentSessionThinkingLevel = result?.thinkingLevel
} else if let thinkingLevel = result?.thinkingLevel {
self.currentSessionThinkingLevel = thinkingLevel
}
let thinkingOptions = result?.thinkingLevels ?? refreshedSnapshot?.thinkingOptions ?? []
self.thinkingOptions = thinkingOptions
if !refreshFailed {
let validated = QuickChatModelControlLogic.validatedThinkingSelection(
self.selectedThinkingLevel,
options: thinkingOptions)
if validated != self.selectedThinkingLevel {
self.selectedThinkingLevel = validated
self.retryIdentity = nil
}
}
self.modelControlStatusMessage = refreshFailed
? String(localized: "Couldn't load model settings.")
: nil
}
self.clearModelPatchState(requestID: requestID)
return true
} catch is CancellationError {
self.clearModelPatchState(requestID: requestID)
return false
} catch {
guard self.modelPatchRequestID == requestID else { return false }
if self.routingTarget == target {
self.modelControlStatusMessage = String(localized: "Couldn't change the model.")
}
self.clearModelPatchState(requestID: requestID)
return false
}
}
private func clearModelPatchState(requestID: UUID) {
guard self.modelPatchRequestID == requestID else { return }
self.isUpdatingModel = false
self.modelPatchTask = nil
self.modelPatchTarget = nil
self.modelPatchSelectionID = nil
}
private func cancelModelControlRefresh() {
self.modelControlsRequestID = UUID()
self.modelControlsTask?.cancel()
self.modelControlsTask = nil
self.isLoadingModelControls = false
}
private func resetDictationState() {
self.isStartingDictation = false
self.isDictating = false
self.dictationStatusMessage = nil
self.dictationSelectionRange = nil
self.dictationTextSession = nil
}
}
extension String {
fileprivate var nonEmptyTrimmed: String? {
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}