Files
openclaw/apps/macos/Sources/OpenClaw/QuickChatModel.swift
Peter Steinberger c8fe8f102f feat(macos): Quick Chat area capture and focused-app text context (#110635)
* feat(macos): add Quick Chat capture context

* docs(macos): describe Quick Chat capture context

* fix(macos): bound the AX text walk with deadline, cancellation, and per-message timeout; keep distinct repeated text

* fix(macos): ancestry-scoped echo suppression and adapter-wide AX timeouts

* fix(macos): hard outer timeout abandons a hung AX walk

* fix(macos): truly abandon hung AX walks; hide the bar synchronously before area selection

* chore(i18n): refresh native locale artifacts for Quick Chat capture strings
2026-07-18 17:45:52 +01:00

811 lines
32 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 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,
[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
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
}
}
}
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?
/// 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?
@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
/// 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>?
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, idempotencyKey, attachments in
let response = try await GatewayConnection.shared.chatSend(
sessionKey: sessionKey,
agentID: agentID,
message: message,
thinking: nil,
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()
})
{
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
}
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 {
(!self.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.textContext != nil) &&
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isCapturingTextContext &&
self.sendState != .sending
}
var canCaptureWindow: Bool {
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
self.sendState != .sending
}
var canCaptureTextContext: Bool {
self.canCaptureWindow && !self.isCapturingTextContext
}
var canSelectRecentSession: Bool {
!self.sessionKey.isEmpty &&
self.connectionGate == .available &&
!self.isGrantingPermissions &&
!self.isCapturingTextContext &&
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.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,
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 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 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()
// 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()
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
return
}
let target = Self.routingTarget(override: self.targetSessionOverride, base: baseRoutingTarget)
self.sessionKey = target.sessionKey
self.sendAgentID = target.agentID
}
private func performSend(
messageOverride: String?,
attachments: [OpenClawChatAttachmentPayload],
draftOverride: String? = nil,
textContextOverride: QuickChatTextContext? = nil,
continuesCapturePipeline: Bool = false) async -> Bool
{
// 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
// 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?(QuickChatRoutingTarget(sessionKey: sessionKey, agentID: agentID))
let idempotencyKey: String
if let retryIdentity = self.retryIdentity,
retryIdentity.draft == draft,
retryIdentity.message == message,
retryIdentity.sessionKey == sessionKey,
retryIdentity.agentID == agentID,
retryIdentity.attachments == attachments
{
idempotencyKey = retryIdentity.idempotencyKey
} else {
idempotencyKey = UUID().uuidString
self.retryIdentity = RetryIdentity(
draft: draft,
message: message,
sessionKey: sessionKey,
agentID: agentID,
attachments: attachments,
idempotencyKey: idempotencyKey)
}
let task = Task {
try await self.sendProvider(sessionKey, agentID, message, 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 = QuickChatRoutingTarget(sessionKey: sessionKey, agentID: agentID)
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 String {
fileprivate var nonEmptyTrimmed: String? {
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}