import Darwin import Foundation import ImageIO import Markdown import Observation import SwiftUI let chatLinkPreviewTitleMaxCharacters = 120 let chatLinkPreviewDescriptionMaxCharacters = 200 let chatLinkPreviewBodyMaxBytes = 512 * 1024 let chatLinkPreviewImageBodyMaxBytes = 1024 * 1024 let chatLinkPreviewImageMaxPixelSize = 600 let chatLinkPreviewImageMaxSourcePixels = 64 * 1024 * 1024 private let chatLinkPreviewMaxRedirects = 3 private let chatLinkPreviewTimeout: TimeInterval = 6 private let chatLinkPreviewCacheEntries = 64 private let chatLinkPreviewImageCacheEntries = 32 struct ChatLinkPreviewMetadata: Equatable { let url: URL let title: String? let description: String? let imageURL: URL? } enum ChatLinkPreviewResult: Equatable { case loaded(ChatLinkPreviewMetadata) case failed } struct ChatLinkPreviewThumbnail: @unchecked Sendable { let image: CGImage } enum ChatLinkPreviewImageResult: @unchecked Sendable { case loaded(ChatLinkPreviewThumbnail) case failed } /// Returns the first HTTP(S) link outside inline and block code. func chatFirstPreviewURL(in markdown: String) -> URL? { chatFirstPreviewURL(in: Document(parsing: markdown)) } private func chatFirstPreviewURL(in markup: any Markup) -> URL? { if markup is InlineCode || markup is CodeBlock { return nil } if let link = markup as? Markdown.Link { return link.destination.flatMap(chatSafeWebURL) } if let text = markup as? Markdown.Text, let bareURL = chatFirstBareWebURL(in: text.string) { return bareURL } for child in markup.children { if let url = chatFirstPreviewURL(in: child) { return url } } return nil } private func chatFirstBareWebURL(in text: String) -> URL? { let pattern = #"(?i)https?://[^\s<>\"`]+"# guard let match = text.range(of: pattern, options: .regularExpression) else { return nil } var candidate = String(text[match]) while let last = candidate.last, ".,;:!?".contains(last) { candidate.removeLast() } for pair: (open: Character, close: Character) in [("(", ")"), ("[", "]"), ("{", "}")] { while candidate.hasSuffix(String(pair.close)), candidate.count(of: pair.close) > candidate.count(of: pair.open) { candidate.removeLast() } } return chatSafeWebURL(candidate) } extension String { fileprivate func count(of character: Character) -> Int { self.reduce(into: 0) { count, current in if current == character { count += 1 } } } } private func chatSafeWebURL(_ value: String) -> URL? { guard let url = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines)), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", url.host != nil else { return nil } return url } func parseChatOpenGraph(html: String, baseURL: URL) -> ChatLinkPreviewResult { var title: String? var description: String? var image: String? for tag in chatHTMLTags(named: "meta", in: html) { let attributes = chatHTMLAttributes(in: tag) let property = (attributes["property"] ?? attributes["name"])?.lowercased() guard let content = attributes["content"] else { continue } switch property { case "og:title" where title == nil: title = content case "og:description" where description == nil: description = content case "og:image" where image == nil, "og:image:url" where image == nil: image = content default: break } } let parsedTitle = chatSanitizeMetadataText( title ?? chatHTMLTitle(in: html), maxCharacters: chatLinkPreviewTitleMaxCharacters) let parsedDescription = chatSanitizeMetadataText( description, maxCharacters: chatLinkPreviewDescriptionMaxCharacters) let imageURL = image.flatMap { value -> URL? in let decoded = chatDecodeHTMLEntities(value).trimmingCharacters(in: .whitespacesAndNewlines) guard let resolved = URL(string: decoded, relativeTo: baseURL)?.absoluteURL, chatSafeWebURL(resolved.absoluteString) != nil else { return nil } return resolved } guard parsedTitle != nil || parsedDescription != nil || imageURL != nil else { return .failed } return .loaded(ChatLinkPreviewMetadata( url: baseURL, title: parsedTitle, description: parsedDescription, imageURL: imageURL)) } private func chatHTMLTags(named name: String, in html: String) -> [String] { var tags: [String] = [] var searchStart = html.startIndex let prefix = "<\(name)" while searchStart < html.endIndex, let start = html.range( of: prefix, options: [.caseInsensitive], range: searchStart.. String.Index? { var quote: Character? var index = start while index < html.endIndex { let character = html[index] if quote == nil, character == "\"" || character == "'" { quote = character } else if character == quote { quote = nil } else if character == ">", quote == nil { return index } index = html.index(after: index) } return nil } private func chatHTMLAttributes(in tag: String) -> [String: String] { let pattern = #"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s>]+))"# guard let expression = try? NSRegularExpression(pattern: pattern) else { return [:] } let range = NSRange(tag.startIndex.. String? { let pattern = #"(?is)]*)?>(.*?)"# guard let expression = try? NSRegularExpression(pattern: pattern), let match = expression.firstMatch( in: html, range: NSRange(html.startIndex.. String? { guard let value else { return nil } let withoutControls = chatDecodeHTMLEntities(value).unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } let collapsed = String(String.UnicodeScalarView(withoutControls)) .split(whereSeparator: \.isWhitespace) .joined(separator: " ") return collapsed.isEmpty ? nil : String(collapsed.prefix(maxCharacters)) } private func chatDecodeHTMLEntities(_ value: String) -> String { let pattern = #"&#(x[0-9a-fA-F]+|[0-9]+);?|&(amp|lt|gt|quot|apos|nbsp);"# guard let expression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return value } var decoded = value let matches = expression.matches( in: value, range: NSRange(value.startIndex.." case "quot": "\"" case "apos": "'" case "nbsp": " " default: String(decoded[fullRange]) } } decoded.replaceSubrange(fullRange, with: replacement) } return decoded } func chatLinkPreviewAllowsHost(_ url: URL) -> Bool { guard chatSafeWebURL(url.absoluteString) != nil, var rawHost = url.host?.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")), rawHost != "localhost", !rawHost.hasSuffix(".localhost"), !rawHost.hasSuffix(".local"), !rawHost.contains("%") else { return false } if rawHost.hasPrefix("["), rawHost.hasSuffix("]") { rawHost.removeFirst() rawHost.removeLast() } guard let address = chatParsedIPAddress(rawHost) else { return true } return chatLinkPreviewAllowsAddress(address) } private enum ChatIPAddress { case v4([UInt8]) case v6([UInt8]) } private func chatParsedIPAddress(_ host: String) -> ChatIPAddress? { var ipv4 = in_addr() if host.withCString({ inet_aton($0, &ipv4) }) == 1 { return .v4(withUnsafeBytes(of: &ipv4) { Array($0) }) } var ipv6 = in6_addr() if host.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { return .v6(withUnsafeBytes(of: &ipv6) { Array($0) }) } return nil } private func chatLinkPreviewAllowsAddress(_ address: ChatIPAddress) -> Bool { switch address { case let .v4(bytes): guard bytes.count == 4 else { return false } let first = Int(bytes[0]) let second = Int(bytes[1]) let third = Int(bytes[2]) return !(first == 0 || first == 10 || (first == 100 && (64...127).contains(second)) || first == 127 || (first == 169 && second == 254) || (first == 172 && (16...31).contains(second)) || (first == 192 && second == 0 && (third == 0 || third == 2)) || (first == 192 && second == 88 && third == 99) || (first == 192 && second == 168) || (first == 198 && (18...19).contains(second)) || (first == 198 && second == 51 && third == 100) || (first == 203 && second == 0 && third == 113) || first >= 224) case let .v6(bytes): guard bytes.count == 16 else { return false } let globalUnicast = bytes[0] & 0xE0 == 0x20 let special2001 = bytes.hasPrefix([0x20, 0x01, 0x00]) let orchid = special2001 && (bytes[3] & 0xF0 == 0x10 || bytes[3] & 0xF0 == 0x20) return globalUnicast && !bytes.hasPrefix([0x20, 0x01, 0x00, 0x00]) && !bytes.hasPrefix([0x20, 0x01, 0x00, 0x02]) && !orchid && !bytes.hasPrefix([0x20, 0x01, 0x0D, 0xB8]) && !bytes.hasPrefix([0x20, 0x02]) && !(bytes.hasPrefix([0x3F, 0xFF]) && bytes[2] & 0xF0 == 0) } } private func chatLinkPreviewAllowsRemoteAddress(_ address: String) -> Bool { guard let parsed = chatParsedIPAddress(address) else { return false } return chatLinkPreviewAllowsAddress(parsed) } extension [UInt8] { fileprivate func hasPrefix(_ prefix: [UInt8]) -> Bool { self.count >= prefix.count && self.indices.prefix(prefix.count).allSatisfy { self[$0] == prefix[$0] } } } func chatLinkPreviewRedirectURL( request: URLRequest, redirectCount: Int, hostPolicy: (URL) -> Bool = chatLinkPreviewAllowsHost) -> URL? { guard redirectCount < chatLinkPreviewMaxRedirects, let url = request.url, chatSafeWebURL(url.absoluteString) != nil, hostPolicy(url) else { return nil } return url } struct ChatLinkPreviewBodyAccumulator { private let maxBytes: Int private(set) var data = Data() init(maxBytes: Int = chatLinkPreviewBodyMaxBytes) { self.maxBytes = maxBytes } mutating func append(_ chunk: Data) -> Bool { let remaining = self.maxBytes - self.data.count guard remaining > 0 else { return true } self.data.append(chunk.prefix(remaining)) return chunk.count > remaining } } private enum ChatLinkPreviewFetchMode { case metadata case image var accept: String { switch self { case .metadata: "text/html" case .image: "image/*" } } var allowedMIMETypes: Set { switch self { case .metadata: ["text/html"] case .image: ["image/jpeg", "image/png", "image/webp", "image/gif"] } } var maxBodyBytes: Int { switch self { case .metadata: chatLinkPreviewBodyMaxBytes case .image: chatLinkPreviewImageBodyMaxBytes } } var rejectsCappedBody: Bool { self == .image } } final class ChatLinkPreviewFetcher: @unchecked Sendable { typealias HostPolicy = @Sendable (URL) -> Bool typealias ConnectionPolicy = @Sendable ([String?]) -> Bool private let configuration: URLSessionConfiguration private let timeout: TimeInterval private let hostPolicy: HostPolicy private let connectionPolicy: ConnectionPolicy init( configuration: URLSessionConfiguration = .chatLinkPreview, timeout: TimeInterval = chatLinkPreviewTimeout, hostPolicy: @escaping HostPolicy = chatLinkPreviewAllowsHost, connectionPolicy: @escaping ConnectionPolicy = ChatLinkPreviewFetcher.publicConnectionsOnly) { self.configuration = configuration self.timeout = timeout self.hostPolicy = hostPolicy self.connectionPolicy = connectionPolicy } func fetch(_ originalURL: URL) async -> ChatLinkPreviewResult { guard let response = await self.fetchResponse(originalURL, mode: .metadata), let html = String(bytes: response.data, encoding: .utf8) else { return .failed } return switch parseChatOpenGraph(html: html, baseURL: response.url) { case let .loaded(metadata): .loaded(ChatLinkPreviewMetadata( url: originalURL, title: metadata.title, description: metadata.description, imageURL: metadata.imageURL)) case .failed: .failed } } func fetchImage(_ originalURL: URL) async -> ChatLinkPreviewImageResult { guard let response = await self.fetchResponse(originalURL, mode: .image), let thumbnail = chatDecodeLinkPreviewThumbnail( response.data, mimeType: response.mimeType) else { return .failed } return .loaded(thumbnail) } private func fetchResponse( _ originalURL: URL, mode: ChatLinkPreviewFetchMode) async -> ChatLinkPreviewResponse? { guard chatSafeWebURL(originalURL.absoluteString) != nil, self.hostPolicy(originalURL) else { return nil } let delegate = ChatLinkPreviewSessionDelegate( mode: mode, hostPolicy: self.hostPolicy, connectionPolicy: self.connectionPolicy) let session = URLSession(configuration: self.configuration, delegate: delegate, delegateQueue: nil) var request = URLRequest(url: originalURL, timeoutInterval: self.timeout) request.httpMethod = "GET" request.setValue(mode.accept, forHTTPHeaderField: "Accept") request.httpShouldHandleCookies = false let task = session.dataTask(with: request) let deadline = Task { try? await Task.sleep(for: .seconds(self.timeout)) guard !Task.isCancelled else { return } delegate.abort(task) } let response = await withTaskCancellationHandler { await withCheckedContinuation { continuation in delegate.start(continuation) task.resume() } } onCancel: { delegate.abort(task) } deadline.cancel() session.finishTasksAndInvalidate() return response } private static func publicConnectionsOnly(_ addresses: [String?]) -> Bool { !addresses.isEmpty && addresses.allSatisfy { address in address.map(chatLinkPreviewAllowsRemoteAddress) == true } } } extension URLSessionConfiguration { fileprivate static var chatLinkPreview: URLSessionConfiguration { let configuration = URLSessionConfiguration.ephemeral configuration.httpCookieAcceptPolicy = .never configuration.httpShouldSetCookies = false configuration.urlCredentialStorage = nil configuration.urlCache = nil configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData configuration.timeoutIntervalForRequest = chatLinkPreviewTimeout configuration.timeoutIntervalForResource = chatLinkPreviewTimeout configuration.connectionProxyDictionary = [:] return configuration } } private struct ChatLinkPreviewResponse { let url: URL let mimeType: String let data: Data } private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { private let lock = NSLock() private let mode: ChatLinkPreviewFetchMode private let hostPolicy: ChatLinkPreviewFetcher.HostPolicy private let connectionPolicy: ChatLinkPreviewFetcher.ConnectionPolicy private var continuation: CheckedContinuation? private var responseURL: URL? private var body: ChatLinkPreviewBodyAccumulator private var responseMIMEType: String? private var redirectCount = 0 private var remoteAddresses: [String?] = [] private var bodyCapped = false private var failed = false private var completed = false init( mode: ChatLinkPreviewFetchMode, hostPolicy: @escaping ChatLinkPreviewFetcher.HostPolicy, connectionPolicy: @escaping ChatLinkPreviewFetcher.ConnectionPolicy) { self.mode = mode self.hostPolicy = hostPolicy self.connectionPolicy = connectionPolicy self.body = ChatLinkPreviewBodyAccumulator(maxBytes: mode.maxBodyBytes) } func start(_ continuation: CheckedContinuation) { let didAlreadyComplete = self.lock.withLock { if self.completed { return true } self.continuation = continuation return false } if didAlreadyComplete { continuation.resume(returning: nil) } } func abort(_ task: URLSessionTask) { task.cancel() self.complete(nil) } func urlSession( _: URLSession, task _: URLSessionTask, willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { let nextURL = self.lock.withLock { let url = chatLinkPreviewRedirectURL( request: request, redirectCount: self.redirectCount, hostPolicy: self.hostPolicy) if url != nil { self.redirectCount += 1 } return url } completionHandler(nextURL == nil ? nil : request) } func urlSession( _: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let mimeType = http.mimeType?.lowercased(), self.mode.allowedMIMETypes.contains(mimeType), let url = http.url else { self.lock.withLock { self.failed = true } completionHandler(.cancel) return } self.lock.withLock { self.responseURL = url self.responseMIMEType = mimeType } completionHandler(.allow) } func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { let reachedCap = self.lock.withLock { let reachedCap = self.body.append(data) if reachedCap { self.bodyCapped = true } return reachedCap } if reachedCap { dataTask.cancel() } } func urlSession(_: URLSession, task _: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { // URLSession has no DNS hook. Pre-flight rejects unsafe literals; after connection, // every transaction's actual peer address is required and re-validated. The body is // discarded if Foundation omits metrics or reports any non-public address. self.lock.withLock { self.remoteAddresses.append(contentsOf: metrics.transactionMetrics.map(\.remoteAddress)) } } func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: (any Error)?) { let result = self.lock.withLock { () -> ChatLinkPreviewResponse? in guard !self.failed, error == nil || self.bodyCapped, let responseURL, let responseMIMEType, !(self.mode.rejectsCappedBody && self.bodyCapped), self.connectionPolicy(self.remoteAddresses) else { return nil } return ChatLinkPreviewResponse( url: responseURL, mimeType: responseMIMEType, data: self.body.data) } self.complete(result) } private func complete(_ result: ChatLinkPreviewResponse?) { let continuation = self.lock.withLock { () -> CheckedContinuation? in guard !self.completed else { return nil } self.completed = true defer { self.continuation = nil } return self.continuation } continuation?.resume(returning: result) } } func chatDecodeLinkPreviewThumbnail( _ data: Data, mimeType: String, maxSourcePixels: Int = chatLinkPreviewImageMaxSourcePixels) -> ChatLinkPreviewThumbnail? { guard let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetCount(source) > 0, mimeType != "image/gif" || CGImageSourceGetCount(source) == 1, let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, width > 0, height > 0, height <= maxSourcePixels, width <= maxSourcePixels / height else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: chatLinkPreviewImageMaxPixelSize, kCGImageSourceShouldCacheImmediately: true, ] guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } return ChatLinkPreviewThumbnail(image: image) } @MainActor final class ChatLinkPreviewStore { typealias Fetch = @Sendable (URL) async -> ChatLinkPreviewResult private let fetch: Fetch private let maxEntries: Int private var cache: [URL: ChatLinkPreviewResult] = [:] private var recency: [URL] = [] init(maxEntries: Int = chatLinkPreviewCacheEntries, fetch: @escaping Fetch) { self.maxEntries = maxEntries self.fetch = fetch } func get(_ url: URL) async -> ChatLinkPreviewResult { if let cached = self.cache[url] { self.touch(url) return cached } let result = await self.fetch(url) guard !Task.isCancelled else { return result } self.cache[url] = result self.touch(url) while self.recency.count > self.maxEntries, let evicted = self.recency.first { self.recency.removeFirst() self.cache.removeValue(forKey: evicted) } return result } private func touch(_ url: URL) { self.recency.removeAll { $0 == url } self.recency.append(url) } } @MainActor final class ChatLinkPreviewImageStore { typealias Fetch = @Sendable (URL) async -> ChatLinkPreviewImageResult private let fetch: Fetch private let maxEntries: Int private var cache: [URL: ChatLinkPreviewImageResult] = [:] private var recency: [URL] = [] init(maxEntries: Int = chatLinkPreviewImageCacheEntries, fetch: @escaping Fetch) { self.maxEntries = maxEntries self.fetch = fetch } func get(_ url: URL) async -> ChatLinkPreviewImageResult { if let cached = self.cache[url] { self.touch(url) return cached } let result = await self.fetch(url) guard !Task.isCancelled else { return result } self.cache[url] = result self.touch(url) while self.recency.count > self.maxEntries, let evicted = self.recency.first { self.recency.removeFirst() self.cache.removeValue(forKey: evicted) } return result } private func touch(_ url: URL) { self.recency.removeAll { $0 == url } self.recency.append(url) } } @MainActor private let chatLinkPreviewStore = ChatLinkPreviewStore(fetch: ChatLinkPreviewFetcher().fetch) @MainActor private let chatLinkPreviewImageStore = ChatLinkPreviewImageStore(fetch: ChatLinkPreviewFetcher().fetchImage) @MainActor @Observable final class ChatLinkPreviewModel { typealias MetadataFetch = @Sendable (URL) async -> ChatLinkPreviewResult typealias ImageFetch = @Sendable (URL) async -> ChatLinkPreviewImageResult var expanded = false private(set) var result: ChatLinkPreviewResult? private(set) var imageResult: ChatLinkPreviewImageResult? private let metadataFetch: MetadataFetch private let imageFetch: ImageFetch init(metadataFetch: @escaping MetadataFetch, imageFetch: @escaping ImageFetch) { self.metadataFetch = metadataFetch self.imageFetch = imageFetch } var imageURL: URL? { guard case let .loaded(metadata) = self.result else { return nil } return metadata.imageURL } func loadMetadata(_ url: URL) async { guard self.expanded, self.result == nil else { return } self.result = await self.metadataFetch(url) } func loadImage() async { guard self.expanded, self.imageResult == nil, let imageURL = self.imageURL else { return } let result = await self.imageFetch(imageURL) guard !Task.isCancelled else { return } self.imageResult = result } } @MainActor struct ChatLinkPreview: View { @Environment(\.openURL) private var openURL let url: URL @State private var model: ChatLinkPreviewModel init(url: URL) { self.url = url self._model = State(initialValue: ChatLinkPreviewModel( metadataFetch: chatLinkPreviewStore.get, imageFetch: chatLinkPreviewImageStore.get)) } var body: some View { if self.model.expanded { self.expandedCard .task(id: self.url) { await self.model.loadMetadata(self.url) } .task(id: self.model.imageURL) { await self.model.loadImage() } } else { self.collapsedChip } } private var collapsedChip: some View { Button { self.model.expanded = true } label: { HStack(spacing: 6) { Text("Preview · \(self.domain)") .font(OpenClawChatTypography.captionSemiBold) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) .lineLimit(1) Spacer(minLength: 0) Image(systemName: "chevron.down") .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) .accessibilityHidden(true) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(OpenClawChatTheme.subtleCard) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(OpenClawChatTheme.divider, lineWidth: 1)) } .buttonStyle(.plain) .accessibilityLabel("Expand link preview for \(self.domain)") } private var expandedCard: some View { Button { self.openURL(self.url) } label: { VStack(alignment: .leading, spacing: 3) { if case let .loaded(thumbnail) = self.model.imageResult { Image(decorative: thumbnail.image, scale: 1) .resizable() .scaledToFill() .frame(maxWidth: .infinity) .frame(height: 120) .clipped() .accessibilityHidden(true) } Text(self.domain) .font(OpenClawChatTypography.caption2) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) .lineLimit(1) switch self.model.result { case nil: Text("Loading preview…") .font(OpenClawChatTypography.caption) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) case .failed: Text("No preview available") .font(OpenClawChatTypography.callout) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) case let .loaded(metadata): if let title = metadata.title { Text(title) .font(OpenClawChatTypography.footnoteSemiBold) .foregroundStyle(OpenClawChatTheme.assistantText) .lineLimit(2) } if let description = metadata.description { Text(description) .font(OpenClawChatTypography.caption) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) .lineLimit(1) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) .padding(.vertical, 8) .background(OpenClawChatTheme.subtleCard) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(OpenClawChatTheme.divider, lineWidth: 1)) } .buttonStyle(.plain) .accessibilityLabel("Open \(self.domain)") } private var domain: String { let host = self.url.host?.lowercased() ?? self.url.absoluteString return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host } }