From d5b10f385e7452759e17d93beacd2f865a9efed1 Mon Sep 17 00:00:00 2001 From: vladharl <50050915+vladharl@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:44:00 -0500 Subject: [PATCH 1/2] Add Ed25519 device identity and workspace file sync on connect Implement device identity (Ed25519 keypair) for gateway authentication, enabling operator.read and operator.write scopes when connecting through Cloudflare Tunnel or other remote proxies. Without device identity, the gateway strips scopes from non-local connections. Also fetch IDENTITY.md and USER.md from the gateway workspace on connect so the app displays the agent's identity and user profile immediately, rather than only updating when the agent writes to those files. Changes: - New DeviceIdentity service: generates and persists an Ed25519 keypair in Keychain, derives device ID (SHA-256 of public key), and signs the gateway connect handshake payload (v2 protocol with nonce) - ChatService: include device identity in connect request params - ChatService: fetch IDENTITY.md and USER.md via agents.files.get after hello-ok, parse responses from payload.file.content Co-Authored-By: Claude Opus 4.6 --- Chowder/Chowder/Services/ChatService.swift | 81 +++++++++++++++- Chowder/Chowder/Services/DeviceIdentity.swift | 93 +++++++++++++++++++ 2 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 Chowder/Chowder/Services/DeviceIdentity.swift diff --git a/Chowder/Chowder/Services/ChatService.swift b/Chowder/Chowder/Services/ChatService.swift index f8653f8..ec0ada0 100644 --- a/Chowder/Chowder/Services/ChatService.swift +++ b/Chowder/Chowder/Services/ChatService.swift @@ -319,6 +319,33 @@ final class ChatService: NSObject { } } + // MARK: - Private: Workspace File Sync + + /// Fetch a workspace file from the gateway after connecting. + private func fetchWorkspaceFile(_ fileName: String) { + let requestId = makeRequestId() + let frame: [String: Any] = [ + "type": "req", + "id": requestId, + "method": "agents.files.get", + "params": [ + "agentId": "main", + "name": fileName + ] + ] + guard let data = try? JSONSerialization.data(withJSONObject: frame), + let jsonString = String(data: data, encoding: .utf8) else { + log("[SYNC] ❌ Failed to serialize \(fileName) fetch request") + return + } + log("[SYNC] Fetching \(fileName)") + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error { + self?.log("[SYNC] ❌ Error fetching \(fileName): \(error.localizedDescription)") + } + } + } + // MARK: - Private: Connect Handshake /// Send the `connect` request after receiving the gateway's challenge nonce. @@ -328,7 +355,20 @@ final class ChatService: NSObject { // Valid client IDs: webchat-ui, openclaw-control-ui, webchat, cli, // gateway-client, openclaw-macos, openclaw-ios, openclaw-android, node-host, test // Valid client modes: webchat, cli, ui, backend, node, probe, test - // Device identity is schema-optional; omit until we implement keypair signing. + let clientId = "openclaw-ios" + let clientMode = "ui" + let role = "operator" + let scopes = ["operator.read", "operator.write"] + + let signed = DeviceIdentity.sign( + clientId: clientId, + clientMode: clientMode, + role: role, + scopes: scopes, + token: token, + nonce: nonce + ) + let frame: [String: Any] = [ "type": "req", "id": requestId, @@ -337,16 +377,23 @@ final class ChatService: NSObject { "minProtocol": 3, "maxProtocol": 3, "client": [ - "id": "openclaw-ios", + "id": clientId, "version": "1.0.0", "platform": "ios", - "mode": "ui" + "mode": clientMode ], - "role": "operator", - "scopes": ["operator.read", "operator.write"], + "role": role, + "scopes": scopes, "auth": [ "token": token ], + "device": [ + "id": DeviceIdentity.deviceId, + "publicKey": DeviceIdentity.publicKeyBase64Url, + "signature": signed.signature, + "signedAt": signed.signedAt, + "nonce": nonce + ], "locale": Locale.current.identifier, "userAgent": "chowder-ios/1.0.0" ] @@ -610,6 +657,30 @@ final class ChatService: NSObject { self?.isConnected = true self?.delegate?.chatServiceDidConnect() } + + // Fetch IDENTITY.md and USER.md from workspace + fetchWorkspaceFile("IDENTITY.md") + fetchWorkspaceFile("USER.md") + return + } + + // Handle agents.files.get response (workspace file sync) + if let file = payload?["file"] as? [String: Any], + let content = file["content"] as? String, + let filePath = (file["name"] as? String) ?? (file["path"] as? String) { + if filePath.hasSuffix("IDENTITY.md") { + let identity = BotIdentity.from(markdown: content) + log("[SYNC] Fetched IDENTITY.md — name=\(identity.name)") + DispatchQueue.main.async { [weak self] in + self?.delegate?.chatServiceDidUpdateBotIdentity(identity) + } + } else if filePath.hasSuffix("USER.md") { + let profile = UserProfile.from(markdown: content) + log("[SYNC] Fetched USER.md — name=\(profile.name)") + DispatchQueue.main.async { [weak self] in + self?.delegate?.chatServiceDidUpdateUserProfile(profile) + } + } return } diff --git a/Chowder/Chowder/Services/DeviceIdentity.swift b/Chowder/Chowder/Services/DeviceIdentity.swift new file mode 100644 index 0000000..c99f5ca --- /dev/null +++ b/Chowder/Chowder/Services/DeviceIdentity.swift @@ -0,0 +1,93 @@ +import Foundation +import CryptoKit +import CommonCrypto + +/// Manages Ed25519 device identity for OpenClaw gateway authentication. +/// The keypair is generated once and stored in Keychain. The device ID +/// is the SHA-256 hash of the raw public key bytes (hex-encoded). +enum DeviceIdentity { + + private static let privateKeyTag = "deviceEd25519PrivateKey" + + // MARK: - Public API + + /// Returns the device ID (SHA-256 of raw public key, hex-encoded). + static var deviceId: String { + let key = loadOrCreatePrivateKey() + let rawPublicKey = key.publicKey.rawRepresentation + return SHA256.hash(data: rawPublicKey) + .map { String(format: "%02x", $0) } + .joined() + } + + /// Returns the raw public key as base64url (no padding). + static var publicKeyBase64Url: String { + let key = loadOrCreatePrivateKey() + return base64UrlEncode(key.publicKey.rawRepresentation) + } + + /// Signs the device auth payload for the gateway connect handshake. + /// Payload format (v2): version|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce + static func sign( + clientId: String, + clientMode: String, + role: String, + scopes: [String], + token: String, + nonce: String + ) -> (signature: String, signedAt: Int64) { + let key = loadOrCreatePrivateKey() + let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + let scopesStr = scopes.joined(separator: ",") + + let payload = [ + "v2", + deviceId, + clientId, + clientMode, + role, + scopesStr, + String(signedAtMs), + token, + nonce + ].joined(separator: "|") + + let payloadData = Data(payload.utf8) + let signatureRaw = try! key.signature(for: payloadData) + let signatureData = signatureRaw.withUnsafeBytes { Data($0) } + let signatureBase64Url = base64UrlEncode(signatureData) + + return (signatureBase64Url, signedAtMs) + } + + // MARK: - Private + + private static func loadOrCreatePrivateKey() -> Curve25519.Signing.PrivateKey { + if let stored = loadPrivateKey() { + return stored + } + let newKey = Curve25519.Signing.PrivateKey() + savePrivateKey(newKey) + return newKey + } + + private static func loadPrivateKey() -> Curve25519.Signing.PrivateKey? { + guard let b64 = KeychainService.load(key: privateKeyTag), + let data = Data(base64Encoded: b64) else { + return nil + } + return try? Curve25519.Signing.PrivateKey(rawRepresentation: data) + } + + private static func savePrivateKey(_ key: Curve25519.Signing.PrivateKey) { + let b64 = key.rawRepresentation.base64EncodedString() + KeychainService.save(key: privateKeyTag, value: b64) + } + + private static func base64UrlEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} From 3ed31a55efecdd9c0f44da629c37e62a75644fa8 Mon Sep 17 00:00:00 2001 From: vladharl <50050915+vladharl@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:38:58 -0500 Subject: [PATCH 2/2] Add voice I/O, image input, multi-session, message search, and header tab switcher Replace bottom tab bar with a compact 3-segment pill toggle (Sessions/Chat/Cron) in the header. Add voice input via SFSpeechRecognizer with mic button, TTS output via AVSpeechSynthesizer with speaker toggle, image/camera input with PhotosPicker and base64 gateway attachments, multi-session management with per-session message storage, and message search with highlighted results and scroll-to-message. Co-Authored-By: Claude Opus 4.6 --- Chowder/Chowder/Info.plist | 8 + Chowder/Chowder/Models/Message.swift | 4 +- Chowder/Chowder/Models/SavedSession.swift | 22 + Chowder/Chowder/Services/ChatService.swift | 319 +++++++++++++- Chowder/Chowder/Services/LocalStorage.swift | 85 +++- .../Chowder/Services/VoiceInputManager.swift | 122 ++++++ .../Chowder/Services/VoiceOutputManager.swift | 91 ++++ .../Chowder/ViewModels/ChatViewModel.swift | 413 ++++++++++++++++-- Chowder/Chowder/Views/ChatHeaderView.swift | 111 +++-- Chowder/Chowder/Views/ChatView.swift | 134 +++++- Chowder/Chowder/Views/CronJobsView.swift | 155 +++++++ Chowder/Chowder/Views/ImagePickerView.swift | 127 ++++++ Chowder/Chowder/Views/MainTabView.swift | 61 +++ Chowder/Chowder/Views/MessageBubbleView.swift | 47 +- Chowder/Chowder/Views/MessageSearchView.swift | 131 ++++++ Chowder/Chowder/Views/SessionListView.swift | 141 ++++++ 16 files changed, 1857 insertions(+), 114 deletions(-) create mode 100644 Chowder/Chowder/Models/SavedSession.swift create mode 100644 Chowder/Chowder/Services/VoiceInputManager.swift create mode 100644 Chowder/Chowder/Services/VoiceOutputManager.swift create mode 100644 Chowder/Chowder/Views/CronJobsView.swift create mode 100644 Chowder/Chowder/Views/ImagePickerView.swift create mode 100644 Chowder/Chowder/Views/MainTabView.swift create mode 100644 Chowder/Chowder/Views/MessageSearchView.swift create mode 100644 Chowder/Chowder/Views/SessionListView.swift diff --git a/Chowder/Chowder/Info.plist b/Chowder/Chowder/Info.plist index 6a6654d..f874666 100644 --- a/Chowder/Chowder/Info.plist +++ b/Chowder/Chowder/Info.plist @@ -7,5 +7,13 @@ NSAllowsArbitraryLoads + NSMicrophoneUsageDescription + Chowder uses the microphone for voice input so you can speak your messages. + NSSpeechRecognitionUsageDescription + Chowder uses speech recognition to transcribe your voice into text messages. + NSCameraUsageDescription + Chowder uses the camera to take photos for the agent to analyze. + NSPhotoLibraryUsageDescription + Chowder accesses your photo library to send images to the agent. diff --git a/Chowder/Chowder/Models/Message.swift b/Chowder/Chowder/Models/Message.swift index 26fb86c..b37c07c 100644 --- a/Chowder/Chowder/Models/Message.swift +++ b/Chowder/Chowder/Models/Message.swift @@ -10,11 +10,13 @@ struct Message: Identifiable, Codable { let role: MessageRole var content: String let timestamp: Date + var imageData: Data? - init(id: UUID = UUID(), role: MessageRole, content: String, timestamp: Date = Date()) { + init(id: UUID = UUID(), role: MessageRole, content: String, timestamp: Date = Date(), imageData: Data? = nil) { self.id = id self.role = role self.content = content self.timestamp = timestamp + self.imageData = imageData } } diff --git a/Chowder/Chowder/Models/SavedSession.swift b/Chowder/Chowder/Models/SavedSession.swift new file mode 100644 index 0000000..7e5270b --- /dev/null +++ b/Chowder/Chowder/Models/SavedSession.swift @@ -0,0 +1,22 @@ +import Foundation + +struct SavedSession: Identifiable, Codable, Equatable { + let id: UUID + var key: String // e.g. "agent:main:main" + var label: String // user-friendly label e.g. "Main", "Research" + var lastUsed: Date + var messageCount: Int + + init(id: UUID = UUID(), key: String, label: String, lastUsed: Date = Date(), messageCount: Int = 0) { + self.id = id + self.key = key + self.label = label + self.lastUsed = lastUsed + self.messageCount = messageCount + } + + /// Default session matching the app's initial config. + static var defaultSession: SavedSession { + SavedSession(key: "agent:main:main", label: "Main") + } +} diff --git a/Chowder/Chowder/Services/ChatService.swift b/Chowder/Chowder/Services/ChatService.swift index ec0ada0..90a1e4a 100644 --- a/Chowder/Chowder/Services/ChatService.swift +++ b/Chowder/Chowder/Services/ChatService.swift @@ -11,8 +11,11 @@ protocol ChatServiceDelegate: AnyObject { func chatServiceDidReceiveThinkingDelta(_ text: String) func chatServiceDidReceiveToolEvent(name: String, path: String?, args: [String: Any]?) func chatServiceDidUpdateBotIdentity(_ identity: BotIdentity) + func chatServiceDidUpdateAvatar(_ image: UIImage) func chatServiceDidUpdateUserProfile(_ profile: UserProfile) func chatServiceDidReceiveHistoryMessages(_ messages: [[String: Any]]) + func chatServiceDidReceiveApproval(_ request: ApprovalRequest) + func chatServiceDidReceiveNotPaired() } final class ChatService: NSObject { @@ -211,6 +214,89 @@ final class ChatService: NSObject { } } + /// Send a message with an image attachment. + /// The gateway expects `message` as a string, so we embed the image as a + /// base64 data URI in the `attachments` array and send text separately. + func sendWithImage(text: String, imageData: Data) { + guard isConnected else { + log("[SEND] ⚠️ Not connected — dropping message") + return + } + + let requestId = makeRequestId() + let idempotencyKey = UUID().uuidString + let base64Image = imageData.base64EncodedString() + + // Gateway expects: message (string), attachments (array of {mimeType, content}) + // content must be raw base64, NOT a data URI + let messageText = text.isEmpty ? "[Attached image]" : text + let frame: [String: Any] = [ + "type": "req", + "id": requestId, + "method": "chat.send", + "params": [ + "message": messageText, + "sessionKey": sessionKey, + "idempotencyKey": idempotencyKey, + "deliver": true, + "attachments": [ + [ + "type": "image", + "mimeType": "image/jpeg", + "fileName": "photo.jpg", + "content": base64Image + ] + ] + ] + ] + + guard let data = try? JSONSerialization.data(withJSONObject: frame), + let jsonString = String(data: data, encoding: .utf8) else { return } + + log("[SEND] Sending chat.send with image id=\(requestId) (\(text.count) chars + \(imageData.count) bytes image)") + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error { + self?.log("[SEND] ❌ Error: \(error.localizedDescription)") + DispatchQueue.main.async { + self?.delegate?.chatServiceDidReceiveError(error) + } + } else { + self?.log("[SEND] ✅ chat.send with image sent OK") + } + } + } + + /// Respond to an agent approval request. + func respondToApproval(requestId: String, approved: Bool) { + guard isConnected else { + log("[APPROVAL] ⚠️ Not connected — dropping response") + return + } + + let reqId = makeRequestId() + let frame: [String: Any] = [ + "type": "req", + "id": reqId, + "method": "exec.approval.resolve", + "params": [ + "id": requestId, + "decision": approved ? "allow-once" : "deny" + ] + ] + + guard let data = try? JSONSerialization.data(withJSONObject: frame), + let jsonString = String(data: data, encoding: .utf8) else { return } + + log("[APPROVAL] Sending \(approved ? "approve" : "deny") for \(requestId)") + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error { + self?.log("[APPROVAL] ❌ Error: \(error.localizedDescription)") + } else { + self?.log("[APPROVAL] ✅ Response sent") + } + } + } + /// Request chat history for the current session (for polling during active runs) private func requestChatHistory() { guard isConnected, activeRunId != nil, !historyRequestInFlight else { @@ -346,6 +432,154 @@ final class ChatService: NSObject { } } + // MARK: - Private: Avatar Fetch + + /// Fetch the agent's avatar image from IDENTITY.md's avatar field. + /// Supports http/https URLs and workspace-relative paths (fetched via agents.files.get). + private func fetchAvatarImage(from avatarString: String) { + let trimmed = avatarString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { + // Direct URL download + guard let url = URL(string: trimmed) else { + log("[AVATAR] ❌ Invalid URL: \(trimmed)") + return + } + log("[AVATAR] Downloading from URL: \(trimmed)") + URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + if let error { + self?.log("[AVATAR] ❌ Download error: \(error.localizedDescription)") + return + } + guard let data, let image = UIImage(data: data) else { + self?.log("[AVATAR] ❌ Could not create image from data") + return + } + self?.log("[AVATAR] ✅ Downloaded avatar image") + DispatchQueue.main.async { + self?.delegate?.chatServiceDidUpdateAvatar(image) + } + }.resume() + } else if trimmed.hasPrefix("data:") { + // Data URI (e.g. data:image/png;base64,...) + if let commaIndex = trimmed.firstIndex(of: ",") { + let base64 = String(trimmed[trimmed.index(after: commaIndex)...]) + if let data = Data(base64Encoded: base64), let image = UIImage(data: data) { + log("[AVATAR] ✅ Decoded data URI avatar") + DispatchQueue.main.async { [weak self] in + self?.delegate?.chatServiceDidUpdateAvatar(image) + } + } + } + } else { + // Workspace-relative path — fetch via gateway + log("[AVATAR] Fetching workspace file: \(trimmed)") + fetchAvatarWorkspaceFile(trimmed) + } + } + + /// Fetch an avatar image file from the gateway workspace using agents.files.get. + private func fetchAvatarWorkspaceFile(_ path: String) { + let requestId = makeRequestId() + let frame: [String: Any] = [ + "type": "req", + "id": requestId, + "method": "agents.files.get", + "params": [ + "agentId": "main", + "name": path + ] + ] + guard let data = try? JSONSerialization.data(withJSONObject: frame), + let jsonString = String(data: data, encoding: .utf8) else { return } + log("[AVATAR] Requesting workspace file: \(path)") + // Tag the request so handleResponse can identify avatar file responses + pendingAvatarFileRequest = requestId + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error { + self?.log("[AVATAR] ❌ Error: \(error.localizedDescription)") + } + } + } + + /// Tracks the request ID for an in-flight avatar file fetch. + private var pendingAvatarFileRequest: String? + + /// Generic response handlers keyed by request ID. + /// When a `type:"res"` frame arrives, if a handler exists for its ID, + /// the handler is called and removed — bypassing the default response logic. + private var responseHandlers: [String: (Bool, [String: Any]?) -> Void] = [:] + + /// Send a generic request and register a one-shot response handler. + func sendRequest(method: String, params: [String: Any] = [:], completion: @escaping (Bool, [String: Any]?) -> Void) { + guard isConnected else { + log("[REQUEST] \u{26a0}\u{fe0f} Not connected — dropping \(method)") + completion(false, nil) + return + } + + let requestId = makeRequestId() + responseHandlers[requestId] = completion + + let frame: [String: Any] = [ + "type": "req", + "id": requestId, + "method": method, + "params": params + ] + + guard let data = try? JSONSerialization.data(withJSONObject: frame), + let jsonString = String(data: data, encoding: .utf8) else { + responseHandlers.removeValue(forKey: requestId) + completion(false, nil) + return + } + + log("[REQUEST] Sending \(method) id=\(requestId)") + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error { + self?.log("[REQUEST] \u{274c} Error sending \(method): \(error.localizedDescription)") + DispatchQueue.main.async { + self?.responseHandlers.removeValue(forKey: requestId) + completion(false, nil) + } + } + } + } + + // MARK: - Cron Job Methods + + /// Fetch all cron jobs (including disabled ones). + func fetchCronJobs(completion: @escaping (Bool, [[String: Any]]?) -> Void) { + sendRequest(method: "cron.list", params: ["includeDisabled": true]) { ok, payload in + let jobs = payload?["jobs"] as? [[String: Any]] + completion(ok, jobs) + } + } + + /// Fetch run history for a specific cron job. + func fetchCronRuns(jobId: String, limit: Int = 20, completion: @escaping (Bool, [[String: Any]]?) -> Void) { + sendRequest(method: "cron.runs", params: ["jobId": jobId, "limit": limit]) { ok, payload in + let runs = payload?["runs"] as? [[String: Any]] + completion(ok, runs) + } + } + + /// Fetch cron status overview. + func fetchCronStatus(completion: @escaping (Bool, [String: Any]?) -> Void) { + sendRequest(method: "cron.status") { ok, payload in + completion(ok, payload) + } + } + + /// Trigger a manual cron job run. + func runCronJob(jobId: String, completion: @escaping (Bool, [String: Any]?) -> Void) { + sendRequest(method: "cron.run", params: ["jobId": jobId]) { ok, payload in + completion(ok, payload) + } + } + // MARK: - Private: Connect Handshake /// Send the `connect` request after receiving the gateway's challenge nonce. @@ -358,7 +592,7 @@ final class ChatService: NSObject { let clientId = "openclaw-ios" let clientMode = "ui" let role = "operator" - let scopes = ["operator.read", "operator.write"] + let scopes = ["operator.read", "operator.write", "operator.approvals", "operator.admin"] let signed = DeviceIdentity.sign( clientId: clientId, @@ -394,6 +628,7 @@ final class ChatService: NSObject { "signedAt": signed.signedAt, "nonce": nonce ], + "caps": ["tool-events"], "locale": Locale.current.identifier, "userAgent": "chowder-ios/1.0.0" ] @@ -569,8 +804,11 @@ final class ChatService: NSObject { let content = args?["content"] as? String { if filePath.hasSuffix("IDENTITY.md") { let identity = BotIdentity.from(markdown: content) - self.log("[SYNC] Detected write to IDENTITY.md — name=\(identity.name)") + self.log("[SYNC] Detected write to IDENTITY.md — name=\(identity.name) avatar=\(identity.avatar)") self.delegate?.chatServiceDidUpdateBotIdentity(identity) + if !identity.avatar.isEmpty { + self.fetchAvatarImage(from: identity.avatar) + } } else if filePath.hasSuffix("USER.md") { let profile = UserProfile.from(markdown: content) self.log("[SYNC] Detected write to USER.md — name=\(profile.name)") @@ -634,7 +872,30 @@ final class ChatService: NSObject { self.delegate?.chatServiceDidReceiveError(ChatServiceError.gatewayError(msg)) default: - self.log("[HANDLE] Event: \(event)") + // Check for approval request events (e.g. exec.approval.requested) + if event.contains("approval") { + self.log("[HANDLE] 🔐 Approval event: \(event)") + let requestId = payload?["id"] as? String ?? UUID().uuidString + // The payload has a nested "request" object with command, host, cwd, etc. + let innerRequest = payload?["request"] as? [String: Any] + let command = innerRequest?["command"] as? String ?? "Unknown command" + let cwd = innerRequest?["cwd"] as? String + let host = innerRequest?["host"] as? String ?? "node" + let desc = cwd != nil + ? "Run \(command) in \(cwd!)" + : "Run \(command)" + + let request = ApprovalRequest( + id: requestId, + toolName: "\(host): \(command)", + description: desc, + args: innerRequest, + timestamp: Date() + ) + self.delegate?.chatServiceDidReceiveApproval(request) + } else { + self.log("[HANDLE] Event: \(event)") + } } } } @@ -646,6 +907,20 @@ final class ChatService: NSObject { let payload = json["payload"] as? [String: Any] let error = json["error"] as? [String: Any] + // Check for a registered one-shot response handler first. + if let handler = responseHandlers.removeValue(forKey: id) { + let result = ok ? payload : error + if !ok { + let code = error?["code"] as? String ?? "unknown" + let message = error?["message"] as? String ?? "Request failed" + log("[REQUEST] \u{274c} Response error id=\(id) code=\(code) message=\(message)") + } + DispatchQueue.main.async { + handler(ok, result) + } + return + } + if ok { let payloadType = payload?["type"] as? String @@ -670,10 +945,13 @@ final class ChatService: NSObject { let filePath = (file["name"] as? String) ?? (file["path"] as? String) { if filePath.hasSuffix("IDENTITY.md") { let identity = BotIdentity.from(markdown: content) - log("[SYNC] Fetched IDENTITY.md — name=\(identity.name)") + log("[SYNC] Fetched IDENTITY.md — name=\(identity.name) avatar=\(identity.avatar)") DispatchQueue.main.async { [weak self] in self?.delegate?.chatServiceDidUpdateBotIdentity(identity) } + if !identity.avatar.isEmpty { + fetchAvatarImage(from: identity.avatar) + } } else if filePath.hasSuffix("USER.md") { let profile = UserProfile.from(markdown: content) log("[SYNC] Fetched USER.md — name=\(profile.name)") @@ -684,6 +962,23 @@ final class ChatService: NSObject { return } + // Handle avatar workspace file response (binary content as base64) + if let reqId = pendingAvatarFileRequest, id == reqId { + pendingAvatarFileRequest = nil + if let file = payload?["file"] as? [String: Any], + let contentB64 = file["content"] as? String, + let data = Data(base64Encoded: contentB64), + let image = UIImage(data: data) { + log("[AVATAR] ✅ Loaded avatar from workspace file") + DispatchQueue.main.async { [weak self] in + self?.delegate?.chatServiceDidUpdateAvatar(image) + } + } else { + log("[AVATAR] ⚠️ Could not decode avatar from workspace response") + } + return + } + // Handle chat.history response if payloadType == "chat-history" || payloadType == "history" { if let messages = payload?["messages"] as? [[String: Any]] { @@ -728,8 +1023,20 @@ final class ChatService: NSObject { let code = error?["code"] as? String ?? "unknown" let message = error?["message"] as? String ?? json["error"] as? String ?? "Request failed" log("[HANDLE] ❌ res error id=\(id) code=\(code) message=\(message)") - DispatchQueue.main.async { [weak self] in - self?.delegate?.chatServiceDidReceiveError(ChatServiceError.gatewayError("\(code): \(message)")) + + if code == "NOT_PAIRED" { + // Device identity changed (e.g. Keychain wiped on reinstall). + // Stop reconnect loop and notify delegate to show re-pair UI. + shouldReconnect = false + stopHistoryPolling() + DispatchQueue.main.async { [weak self] in + self?.isConnected = false + self?.delegate?.chatServiceDidReceiveNotPaired() + } + } else { + DispatchQueue.main.async { [weak self] in + self?.delegate?.chatServiceDidReceiveError(ChatServiceError.gatewayError("\(code): \(message)")) + } } } } diff --git a/Chowder/Chowder/Services/LocalStorage.swift b/Chowder/Chowder/Services/LocalStorage.swift index d221948..e18ce9f 100644 --- a/Chowder/Chowder/Services/LocalStorage.swift +++ b/Chowder/Chowder/Services/LocalStorage.swift @@ -11,34 +11,99 @@ enum LocalStorage { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } - // MARK: - Chat History - - private static var chatHistoryURL: URL { - documentsURL.appendingPathComponent("chat_history.json") + /// Per-session directory for message storage. + private static func sessionDirectory(for sessionKey: String) -> URL { + let sanitized = sessionKey + .replacingOccurrences(of: ":", with: "_") + .replacingOccurrences(of: "/", with: "_") + let dir = documentsURL.appendingPathComponent("sessions/\(sanitized)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir } - static func saveMessages(_ messages: [Message]) { + // MARK: - Chat History (per-session) + + static func saveMessages(_ messages: [Message], forSession sessionKey: String) { + let url = sessionDirectory(for: sessionKey).appendingPathComponent("chat_history.json") do { let data = try JSONEncoder().encode(messages) - try data.write(to: chatHistoryURL, options: .atomic) + try data.write(to: url, options: .atomic) } catch { print("[LocalStorage] Failed to save messages: \(error)") } } - static func loadMessages() -> [Message] { + static func loadMessages(forSession sessionKey: String) -> [Message] { + let url = sessionDirectory(for: sessionKey).appendingPathComponent("chat_history.json") + if FileManager.default.fileExists(atPath: url.path) { + do { + let data = try Data(contentsOf: url) + let messages = try JSONDecoder().decode([Message].self, from: data) + if !messages.isEmpty { + return messages + } + // File exists but is empty — fall through to legacy + } catch { + print("[LocalStorage] Failed to load per-session messages: \(error)") + // Fall through to legacy on decode error (e.g. schema change) + } + } + + // Migration: try loading from legacy location + let legacy = loadLegacyMessages() + if !legacy.isEmpty { + print("[LocalStorage] Migrated \(legacy.count) messages from legacy to session \(sessionKey)") + // Save to per-session location so we don't migrate again + saveMessages(legacy, forSession: sessionKey) + } + return legacy + } + + static func deleteMessages(forSession sessionKey: String) { + let url = sessionDirectory(for: sessionKey).appendingPathComponent("chat_history.json") + try? FileManager.default.removeItem(at: url) + } + + // Legacy support (single-session) + private static var chatHistoryURL: URL { + documentsURL.appendingPathComponent("chat_history.json") + } + + private static func loadLegacyMessages() -> [Message] { guard FileManager.default.fileExists(atPath: chatHistoryURL.path) else { return [] } do { let data = try Data(contentsOf: chatHistoryURL) return try JSONDecoder().decode([Message].self, from: data) } catch { - print("[LocalStorage] Failed to load messages: \(error)") + print("[LocalStorage] Failed to load legacy messages: \(error)") return [] } } - static func deleteMessages() { - try? FileManager.default.removeItem(at: chatHistoryURL) + // MARK: - Saved Sessions + + private static var sessionsURL: URL { + documentsURL.appendingPathComponent("saved_sessions.json") + } + + static func saveSessions(_ sessions: [SavedSession]) { + do { + let data = try JSONEncoder().encode(sessions) + try data.write(to: sessionsURL, options: .atomic) + } catch { + print("[LocalStorage] Failed to save sessions: \(error)") + } + } + + static func loadSessions() -> [SavedSession] { + guard FileManager.default.fileExists(atPath: sessionsURL.path) else { return [] } + do { + let data = try Data(contentsOf: sessionsURL) + return try JSONDecoder().decode([SavedSession].self, from: data) + } catch { + print("[LocalStorage] Failed to load sessions: \(error)") + return [] + } } // MARK: - Agent Avatar diff --git a/Chowder/Chowder/Services/VoiceInputManager.swift b/Chowder/Chowder/Services/VoiceInputManager.swift new file mode 100644 index 0000000..ddc3e4b --- /dev/null +++ b/Chowder/Chowder/Services/VoiceInputManager.swift @@ -0,0 +1,122 @@ +import Foundation +import Speech +import AVFoundation + +final class VoiceInputManager { + private(set) var isListening = false + var transcribedText = "" + var error: String? + + private var speechRecognizer: SFSpeechRecognizer? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private let audioEngine = AVAudioEngine() + + /// Called on main thread when listening stops (either manually or from timeout/error). + var onStoppedListening: (() -> Void)? + + init() { + speechRecognizer = SFSpeechRecognizer(locale: Locale.current) + } + + /// Request speech recognition and microphone permissions. + func requestPermissions(completion: @escaping (Bool) -> Void) { + SFSpeechRecognizer.requestAuthorization { status in + DispatchQueue.main.async { + guard status == .authorized else { + self.error = "Speech recognition not authorized" + completion(false) + return + } + AVAudioApplication.requestRecordPermission { granted in + DispatchQueue.main.async { + if !granted { + self.error = "Microphone access not authorized" + } + completion(granted) + } + } + } + } + } + + /// Start speech recognition. + func startListening(onTranscription: @escaping (String) -> Void) { + guard let speechRecognizer, speechRecognizer.isAvailable else { + error = "Speech recognition unavailable" + return + } + + // Stop any existing task + if isListening { + stopListening() + } + recognitionTask?.cancel() + recognitionTask = nil + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + self.error = "Audio session setup failed: \(error.localizedDescription)" + return + } + + recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + guard let recognitionRequest else { return } + recognitionRequest.shouldReportPartialResults = true + + if speechRecognizer.supportsOnDeviceRecognition { + recognitionRequest.requiresOnDeviceRecognition = true + } + + recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] result, error in + guard let self else { return } + if let result { + let text = result.bestTranscription.formattedString + DispatchQueue.main.async { + self.transcribedText = text + onTranscription(text) + } + } + if error != nil || (result?.isFinal ?? false) { + DispatchQueue.main.async { + self.stopListening() + } + } + } + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in + self?.recognitionRequest?.append(buffer) + } + + do { + audioEngine.prepare() + try audioEngine.start() + isListening = true + transcribedText = "" + } catch { + self.error = "Audio engine failed to start: \(error.localizedDescription)" + } + } + + /// Stop speech recognition. + func stopListening() { + guard isListening else { return } + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + recognitionRequest?.endAudio() + recognitionRequest = nil + recognitionTask?.cancel() + recognitionTask = nil + isListening = false + + // Deactivate audio session so TTS or other audio can resume + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + + onStoppedListening?() + } +} diff --git a/Chowder/Chowder/Services/VoiceOutputManager.swift b/Chowder/Chowder/Services/VoiceOutputManager.swift new file mode 100644 index 0000000..5038fcb --- /dev/null +++ b/Chowder/Chowder/Services/VoiceOutputManager.swift @@ -0,0 +1,91 @@ +import Foundation +import AVFoundation + +@Observable +final class VoiceOutputManager: NSObject, AVSpeechSynthesizerDelegate { + var isEnabled = false + var isSpeaking = false + + @ObservationIgnored private let synthesizer = AVSpeechSynthesizer() + @ObservationIgnored private var queue: [String] = [] + + override init() { + super.init() + synthesizer.delegate = self + } + + /// Toggle TTS on/off. When disabled, stops any current speech. + func toggle() { + isEnabled.toggle() + if !isEnabled { + stop() + } + } + + /// Speak a message. If already speaking, queues it. + func speak(_ text: String) { + guard isEnabled, !text.isEmpty else { return } + + // Strip markdown formatting for cleaner speech + let cleaned = text + .replacingOccurrences(of: "**", with: "") + .replacingOccurrences(of: "__", with: "") + .replacingOccurrences(of: "```", with: "") + .replacingOccurrences(of: "`", with: "") + .replacingOccurrences(of: "###", with: "") + .replacingOccurrences(of: "##", with: "") + .replacingOccurrences(of: "#", with: "") + + if synthesizer.isSpeaking { + queue.append(cleaned) + } else { + speakNow(cleaned) + } + } + + /// Stop all speech and clear the queue. + func stop() { + synthesizer.stopSpeaking(at: .immediate) + queue.removeAll() + isSpeaking = false + } + + private func speakNow(_ text: String) { + let utterance = AVSpeechUtterance(string: text) + utterance.rate = AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = 1.0 + utterance.volume = 1.0 + + // Use a high-quality voice if available + if let voice = AVSpeechSynthesisVoice(language: Locale.current.language.languageCode?.identifier ?? "en") { + utterance.voice = voice + } + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, options: .duckOthers) + try session.setActive(true) + } catch { + print("[VoiceOutput] Audio session error: \(error)") + } + + isSpeaking = true + synthesizer.speak(utterance) + } + + // MARK: - AVSpeechSynthesizerDelegate + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + if let next = queue.first { + queue.removeFirst() + speakNow(next) + } else { + isSpeaking = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + isSpeaking = false + } +} diff --git a/Chowder/Chowder/ViewModels/ChatViewModel.swift b/Chowder/Chowder/ViewModels/ChatViewModel.swift index d43058b..9a912cb 100644 --- a/Chowder/Chowder/ViewModels/ChatViewModel.swift +++ b/Chowder/Chowder/ViewModels/ChatViewModel.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import UserNotifications @Observable final class ChatViewModel: ChatServiceDelegate { @@ -35,11 +36,170 @@ final class ChatViewModel: ChatServiceDelegate { var showSettings: Bool = false var debugLog: [String] = [] var showDebugLog: Bool = false + var isInBackground: Bool = false + var pendingApprovals: [ApprovalRequest] = [] + var showNotPairedAlert: Bool = false + @ObservationIgnored private var hasAttemptedIdentityReset = false + + // MARK: - Voice Input / Output + + @ObservationIgnored private let voiceInput = VoiceInputManager() + @ObservationIgnored private let voiceOutput = VoiceOutputManager() + @ObservationIgnored private var voicePermissionGranted: Bool? + + /// Observable state — updated manually when voice manager changes. + var isListening: Bool = false + var isSpeakerEnabled: Bool = false + + func toggleVoiceInput() { + // If already listening, just stop + if voiceInput.isListening { + voiceInput.stopListening() + isListening = false + return + } + + // Check if we already know the permission result + if let granted = voicePermissionGranted { + if granted { + startVoiceListening() + } else { + log("Voice permissions denied — cannot start listening") + } + return + } + + // First time: request permissions + voiceInput.requestPermissions { [weak self] granted in + guard let self else { return } + self.voicePermissionGranted = granted + if granted { + self.startVoiceListening() + } else { + self.log("Voice permissions denied: \(self.voiceInput.error ?? "unknown")") + } + } + } + + private func startVoiceListening() { + voiceInput.onStoppedListening = { [weak self] in + self?.isListening = false + } + voiceInput.startListening { [weak self] text in + self?.inputText = text + } + isListening = true + } + + func toggleSpeaker() { + voiceOutput.toggle() + isSpeakerEnabled = voiceOutput.isEnabled + } + + // MARK: - Image Input + + var stagedImage: UIImage? + var showImagePicker: Bool = false + + /// Whether the send button should be enabled (text or image present). + var canSend: Bool { + let hasText = !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasImage = stagedImage != nil + return (hasText || hasImage) && !isLoading + } + + // MARK: - Multi-Session + + var savedSessions: [SavedSession] = [] + var currentSessionKey: String = ConnectionConfig().sessionKey + + func switchToSession(_ session: SavedSession) { + // Save current session's messages + LocalStorage.saveMessages(messages, forSession: currentSessionKey) + updateSessionMessageCount() + + // Switch + currentSessionKey = session.key + messages = LocalStorage.loadMessages(forSession: session.key) + displayLimit = 50 + + // Update last used + if let idx = savedSessions.firstIndex(where: { $0.id == session.id }) { + savedSessions[idx].lastUsed = Date() + } + LocalStorage.saveSessions(savedSessions) + + // Reconnect with new session key + chatService?.disconnect() + chatService = nil + isConnected = false + + let config = ConnectionConfig() + let service = ChatService( + gatewayURL: config.gatewayURL, + token: config.token, + sessionKey: session.key + ) + service.delegate = self + self.chatService = service + service.connect() + log("Switched to session: \(session.label) (\(session.key))") + } + + func createSession(label: String, key: String) { + let session = SavedSession(key: key, label: label) + savedSessions.append(session) + LocalStorage.saveSessions(savedSessions) + log("Created session: \(label) (\(key))") + } + + func deleteSession(_ session: SavedSession) { + savedSessions.removeAll { $0.id == session.id } + LocalStorage.saveSessions(savedSessions) + LocalStorage.deleteMessages(forSession: session.key) + log("Deleted session: \(session.label)") + } + + func renameSession(_ session: SavedSession, newLabel: String) { + if let idx = savedSessions.firstIndex(where: { $0.id == session.id }) { + savedSessions[idx].label = newLabel + LocalStorage.saveSessions(savedSessions) + } + } + + private func updateSessionMessageCount() { + if let idx = savedSessions.firstIndex(where: { $0.key == currentSessionKey }) { + savedSessions[idx].messageCount = messages.count + savedSessions[idx].lastUsed = Date() + LocalStorage.saveSessions(savedSessions) + } + } + + private func loadSessions() { + savedSessions = LocalStorage.loadSessions() + if savedSessions.isEmpty { + // Create default session + let defaultSession = SavedSession.defaultSession + savedSessions = [defaultSession] + LocalStorage.saveSessions(savedSessions) + } + } + + /// Background task identifier to keep WebSocket alive briefly after backgrounding. + @ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + /// Tracks whether the agent was running when we entered background, so we can + /// check for a missed completion on return to foreground. + @ObservationIgnored private var wasLoadingWhenBackgrounded: Bool = false + /// Snapshot of assistant message count when we backgrounded, to detect new responses. + @ObservationIgnored private var assistantMessageCountAtBackground: Int = 0 // Workspace-synced data from the gateway var botIdentity: BotIdentity = LocalStorage.loadBotIdentity() var userProfile: UserProfile = LocalStorage.loadUserProfile() + /// Current avatar image — observable so views update reactively. + var avatarImage: UIImage? = LocalStorage.loadAvatar() + /// The bot's display name — uses IDENTITY.md name, falls back to "Chowder". var botName: String { botIdentity.name.isEmpty ? "Chowder" : botIdentity.name @@ -70,6 +230,9 @@ final class ChatViewModel: ChatServiceDelegate { private var chatService: ChatService? + /// Expose the chat service so other tabs can make requests (e.g. cron). + var exposedChatService: ChatService? { chatService } + var isConfigured: Bool { ConnectionConfig().isConfigured } @@ -163,6 +326,7 @@ final class ChatViewModel: ChatServiceDelegate { /// Push current tracking state to the Live Activity. private func pushLiveActivityUpdate(isAISubject: Bool = false) { + let approvalTool = pendingApprovals.first(where: { !$0.resolved })?.toolName LiveActivityManager.shared.update( subject: liveActivitySubject, currentIntent: liveActivityBottomText, @@ -171,7 +335,8 @@ final class ChatViewModel: ChatServiceDelegate { secondPreviousIntent: liveActivityGreyIntent, stepNumber: liveActivityStepNumber, costTotal: liveActivityCost, - isAISubject: isAISubject + isAISubject: isAISubject, + pendingApprovalTool: approvalTool ) } @@ -238,11 +403,14 @@ final class ChatViewModel: ChatServiceDelegate { func connect() { log("connect() called") + // Load saved sessions + loadSessions() + // Restore chat history from disk on first launch if messages.isEmpty { - messages = LocalStorage.loadMessages() + messages = LocalStorage.loadMessages(forSession: currentSessionKey) if !messages.isEmpty { - log("Restored \(messages.count) messages from disk") + log("Restored \(messages.count) messages from disk for session \(currentSessionKey)") } } @@ -278,26 +446,36 @@ final class ChatViewModel: ChatServiceDelegate { func send() { log("send() — isConnected=\(isConnected) isLoading=\(isLoading)") let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, !isLoading else { return } + let image = stagedImage + guard (!text.isEmpty || image != nil), !isLoading else { return } + + // Stop voice input if active + if voiceInput.isListening { + voiceInput.stopListening() + isListening = false + } hasPlayedResponseHaptic = false hasReceivedAnyDelta = false responseHaptic.prepare() - messages.append(Message(role: .user, content: text)) + // Build the user message (with optional image attachment) + let userMessage = Message(role: .user, content: text, imageData: image?.jpegData(compressionQuality: 0.7)) + messages.append(userMessage) inputText = "" + stagedImage = nil isLoading = true // Start a fresh activity tracker for this agent turn currentActivity = AgentActivity() currentActivity?.currentLabel = "Thinking..." shimmerStartTime = Date() - + // Increment generation counter and capture start time to filter old items currentRunGeneration += 1 currentRunStartTime = Date() log("Starting new run generation \(currentRunGeneration) at \(currentRunStartTime!)") - + // Clear history parsing state for new run seenThinkingIds.removeAll() seenToolCallIds.removeAll() @@ -308,11 +486,13 @@ final class ChatViewModel: ChatServiceDelegate { messages.append(Message(role: .assistant, content: "")) - LocalStorage.saveMessages(messages) + LocalStorage.saveMessages(messages, forSession: currentSessionKey) + updateSessionMessageCount() // Start the Live Activity immediately (subject will be updated when ready) let agentName = botName - LiveActivityManager.shared.startActivity(agentName: agentName, userTask: text, subject: nil) + let displayText = text.isEmpty ? "[Image]" : text + LiveActivityManager.shared.startActivity(agentName: agentName, userTask: displayText, subject: nil, avatarImage: avatarImage) // Generate AI summary for every message sent // Include up to the last 5 user messages to identify the overall task @@ -331,14 +511,21 @@ final class ChatViewModel: ChatServiceDelegate { } } - chatService?.send(text: text) - log("chatService.send() called") + // Send with or without image + if let imageData = image?.jpegData(compressionQuality: 0.7) { + chatService?.sendWithImage(text: text, imageData: imageData) + log("chatService.sendWithImage() called") + } else { + chatService?.send(text: text) + log("chatService.send() called") + } } func clearMessages() { messages.removeAll() - LocalStorage.deleteMessages() - log("Chat history cleared") + LocalStorage.deleteMessages(forSession: currentSessionKey) + updateSessionMessageCount() + log("Chat history cleared for session \(currentSessionKey)") } // MARK: - ChatServiceDelegate (main chat session) @@ -346,6 +533,7 @@ final class ChatViewModel: ChatServiceDelegate { func chatServiceDidConnect() { log("CONNECTED") isConnected = true + hasAttemptedIdentityReset = false // Workspace sync disabled - identity/profile are updated via tool events // when the agent writes to IDENTITY.md or USER.md @@ -381,14 +569,8 @@ final class ChatViewModel: ChatServiceDelegate { lastCompletedActivity = currentActivity currentActivity = nil shimmerStartTime = nil - // End the Lock Screen Live Activity now that the answer is streaming - let taskTitle = liveActivitySubject - Task { - let completionSummary = await generateCompletionSummary(from: taskTitle) - await MainActor.run { - LiveActivityManager.shared.endActivity(completionSummary: completionSummary) - } - } + // Don't end the Live Activity here — wait for chatServiceDidFinishMessage + // so we can show the full response in the Live Activity. log("Cleared activity on first delta") } } @@ -399,12 +581,18 @@ final class ChatViewModel: ChatServiceDelegate { } func chatServiceDidFinishMessage() { - log("message.done - isLoading was \(isLoading)") - + log("message.done - isLoading was \(isLoading), isInBackground=\(isInBackground)") + + // Fire local notification if app is backgrounded (WebSocket stayed alive via background task) + if isInBackground { + wasLoadingWhenBackgrounded = false + endBackgroundTask() + } + // Force isLoading false isLoading = false hasPlayedResponseHaptic = false - + log("Set isLoading=false, hasPlayedResponseHaptic=false, hasReceivedAnyDelta=\(hasReceivedAnyDelta)") // Mark all remaining in-progress steps as completed @@ -415,13 +603,14 @@ final class ChatViewModel: ChatServiceDelegate { lastCompletedActivity = activity log("Preserved activity with \(activity.steps.count) steps") } - - // End the Lock Screen Live Activity with completion summary + + // End the Lock Screen Live Activity: show "Complete", then the response preview let taskTitle = liveActivitySubject + let responsePreview = messages.last(where: { $0.role == .assistant })?.content Task { let completionSummary = await generateCompletionSummary(from: taskTitle) await MainActor.run { - LiveActivityManager.shared.endActivity(completionSummary: completionSummary) + LiveActivityManager.shared.endActivity(completionSummary: completionSummary, responsePreview: responsePreview) } } @@ -453,13 +642,20 @@ final class ChatViewModel: ChatServiceDelegate { self.messages[lastIdx].content.isEmpty { self.messages.remove(at: lastIdx) self.log("Removed empty assistant bubble (final fetch timeout)") - LocalStorage.saveMessages(self.messages) + LocalStorage.saveMessages(self.messages, forSession: self.currentSessionKey) } } } } - - LocalStorage.saveMessages(messages) + + // Auto-speak the assistant's response if TTS is enabled + if let lastMsg = messages.last(where: { $0.role == .assistant }), + !lastMsg.content.isEmpty { + voiceOutput.speak(lastMsg.content) + } + + LocalStorage.saveMessages(messages, forSession: currentSessionKey) + updateSessionMessageCount() } func chatServiceDidReceiveError(_ error: Error) { @@ -473,7 +669,7 @@ final class ChatViewModel: ChatServiceDelegate { isLoading = false currentActivity = nil LiveActivityManager.shared.endActivity() - LocalStorage.saveMessages(messages) + LocalStorage.saveMessages(messages, forSession: currentSessionKey) } /// Map raw system errors into short, human-friendly messages. @@ -667,6 +863,59 @@ final class ChatViewModel: ChatServiceDelegate { LocalStorage.saveBotIdentity(identity) } + func chatServiceDidUpdateAvatar(_ image: UIImage) { + log("Avatar image updated — saving to local and shared storage") + LocalStorage.saveAvatar(image) + avatarImage = image + } + + func chatServiceDidReceiveApproval(_ request: ApprovalRequest) { + log("🔐 Approval request received: \(request.toolName) — \(request.description)") + pendingApprovals.append(request) + + // Update Live Activity to show waiting for approval + LiveActivityManager.shared.updateIntent("Waiting for approval: \(request.toolName)") + + // Fire notification if backgrounded + if isInBackground { + let content = UNMutableNotificationContent() + content.title = "\(botName) needs approval" + content.body = "\(request.toolName): \(request.description)" + content.sound = .default + let notifRequest = UNNotificationRequest(identifier: request.id, content: content, trigger: nil) + UNUserNotificationCenter.current().add(notifRequest) + } + } + + func handleApprovalResponse(id: String, approved: Bool) { + log("🔐 Approval response: \(id) — \(approved ? "approved" : "denied")") + chatService?.respondToApproval(requestId: id, approved: approved) + + // Mark as resolved in the list + if let index = pendingApprovals.firstIndex(where: { $0.id == id }) { + pendingApprovals[index].resolved = true + pendingApprovals[index].approved = approved + } + } + + func chatServiceDidReceiveNotPaired() { + isConnected = false + isLoading = false + + if !hasAttemptedIdentityReset { + // First attempt: reset keypair and reconnect automatically. + // The gateway will see a fresh device with a valid token and auto-pair it. + hasAttemptedIdentityReset = true + log("🔐 NOT_PAIRED — resetting device identity and reconnecting") + DeviceIdentity.resetIdentity() + reconnect() + } else { + // Already tried resetting — gateway requires manual approval. + log("🔐 NOT_PAIRED again — showing alert for manual re-pair") + showNotPairedAlert = true + } + } + func chatServiceDidUpdateUserProfile(_ profile: UserProfile) { log("User profile updated via tool event — name=\(profile.name)") self.userProfile = profile @@ -738,7 +987,7 @@ final class ChatViewModel: ChatServiceDelegate { hasPlayedResponseHaptic = true responseHaptic.impactOccurred() } - LocalStorage.saveMessages(self.messages) + LocalStorage.saveMessages(self.messages, forSession: self.currentSessionKey) } /// Parse a single history item and update activity @@ -782,7 +1031,7 @@ final class ChatViewModel: ChatServiceDelegate { messages[lastIndex].role == .assistant, messages[lastIndex].content.isEmpty { messages[lastIndex].content = "Error: \(errorMessage)" - LocalStorage.saveMessages(messages) + LocalStorage.saveMessages(messages, forSession: currentSessionKey) } return } @@ -1210,6 +1459,90 @@ final class ChatViewModel: ChatServiceDelegate { // MARK: - Workspace Data Management + // MARK: - Background / Foreground Lifecycle + + /// Called when the app enters background. Starts a background task to keep the + /// WebSocket alive so `chatServiceDidFinishMessage` can fire and send a notification. + func didEnterBackground() { + isInBackground = true + wasLoadingWhenBackgrounded = isLoading + assistantMessageCountAtBackground = messages.filter { $0.role == .assistant }.count + log("📱 Entered background — isLoading=\(isLoading)") + + guard isLoading else { return } + + // Request background execution time (~30s) so the WebSocket can receive the finish event + backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "AgentResponse") { [weak self] in + // Expiration handler — OS is about to suspend us + self?.log("📱 Background task expired") + self?.endBackgroundTask() + } + log("📱 Started background task \(backgroundTaskId.rawValue)") + } + + /// Called when the app returns to foreground. Checks if we missed a completion while away. + func didReturnToForeground() { + let wasBg = isInBackground + isInBackground = false + endBackgroundTask() + log("📱 Returned to foreground — wasLoading=\(wasLoadingWhenBackgrounded)") + + // Clear delivered notifications from the lock screen / notification center + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + + // Dismiss only ended Live Activities — active ones (agent still running) stay. + // This means the activity persists on the lock screen after task completion, + // and clears once the user opens the app (by tapping it or otherwise). + LiveActivityManager.shared.dismissEndedActivities() + + // If agent was running when we backgrounded and finished while we were away, + // the notification should have already fired from chatServiceDidFinishMessage. + // But if the WebSocket died before the event arrived, detect it on reconnect: + // reconnect() is called separately by ChatView, which will re-establish the + // connection. If the agent finished, lifecycle.end will arrive and + // chatServiceDidFinishMessage will fire. But isInBackground is now false. + // So we need a different approach: check after reconnect if loading ended. + if wasLoadingWhenBackgrounded && wasBg { + // Schedule a check after reconnect settles — if the agent completed while + // we were suspended (no lifecycle.end received), the history fetch or + // reconnect will set isLoading=false. Fire notification then. + let gen = currentRunGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self, self.currentRunGeneration == gen else { return } + if !self.isLoading && self.wasLoadingWhenBackgrounded { + // Agent finished while we were away — check if new assistant message appeared + let currentAssistantCount = self.messages.filter { $0.role == .assistant }.count + let lastMsg = self.messages.last(where: { $0.role == .assistant })?.content ?? "" + if currentAssistantCount > self.assistantMessageCountAtBackground || !lastMsg.isEmpty { + // Notification disabled — response is shown in Live Activity instead + } + self.wasLoadingWhenBackgrounded = false + } + } + } + } + + /// Fire a local notification with the latest agent response. + private func fireBackgroundNotification() { + let lastMsg = messages.last(where: { $0.role == .assistant })?.content ?? "" + let body = lastMsg.isEmpty ? "Your agent has replied." : String(lastMsg.prefix(100)) + + let content = UNMutableNotificationContent() + content.title = botName + content.body = body + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + log("🔔 Fired background notification: \(body.prefix(50))") + } + + private func endBackgroundTask() { + guard backgroundTaskId != .invalid else { return } + log("📱 Ending background task \(backgroundTaskId.rawValue)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + backgroundTaskId = .invalid + } + /// Save workspace data to local cache (used by Settings save). func saveWorkspaceData(identity: BotIdentity, profile: UserProfile) { self.botIdentity = identity @@ -1218,4 +1551,18 @@ final class ChatViewModel: ChatServiceDelegate { LocalStorage.saveUserProfile(profile) log("Settings saved to local cache") } + + /// Save a manually uploaded avatar image (from Settings photo picker). + func saveManualAvatar(_ image: UIImage) { + LocalStorage.saveAvatar(image) + avatarImage = image + log("Manual avatar saved") + } + + /// Delete the avatar image (from Settings). + func deleteAvatar() { + LocalStorage.deleteAvatar() + avatarImage = nil + log("Avatar deleted") + } } diff --git a/Chowder/Chowder/Views/ChatHeaderView.swift b/Chowder/Chowder/Views/ChatHeaderView.swift index 2c88b01..3b34a63 100644 --- a/Chowder/Chowder/Views/ChatHeaderView.swift +++ b/Chowder/Chowder/Views/ChatHeaderView.swift @@ -3,19 +3,23 @@ import SwiftUI struct ChatHeaderView: View { let botName: String let isOnline: Bool - var taskSummary: String? + var avatarImage: UIImage? + @Binding var selectedTab: Tab var onSettingsTapped: (() -> Void)? var onDebugTapped: (() -> Void)? + var onSearchTapped: (() -> Void)? + var isSpeakerEnabled: Bool = false + var onSpeakerToggle: (() -> Void)? var body: some View { VStack(spacing: 0) { - HStack(spacing: 10) { - // Avatar + name tappable to open settings + HStack(spacing: 8) { + // Avatar tappable to open settings Button { onSettingsTapped?() } label: { - HStack(spacing: 10) { - if let customAvatar = LocalStorage.loadAvatar() { + HStack(spacing: 8) { + if let customAvatar = avatarImage { Image(uiImage: customAvatar) .resizable() .scaledToFill() @@ -42,48 +46,97 @@ struct ChatHeaderView: View { Text(botName) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.primary) + .lineLimit(1) - HStack(spacing: 4) { - Text(isOnline ? "Online" : "Offline") - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.gray) - - if let summary = taskSummary { - Text("·") - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.gray) - Text(summary) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } + Text(isOnline ? "Online" : "Offline") + .font(.system(size: 12)) + .foregroundStyle(.gray) } } } .buttonStyle(.plain) - Spacer() + Spacer(minLength: 4) + // Speaker toggle (TTS) + Button { + onSpeakerToggle?() + } label: { + Image(systemName: isSpeakerEnabled ? "speaker.wave.2.fill" : "speaker.slash") + .font(.system(size: 16)) + .foregroundStyle(isSpeakerEnabled ? .blue : .gray) + .frame(width: 34, height: 34) + } + + // Search button + Button { + onSearchTapped?() + } label: { + Image(systemName: "magnifyingglass") + .font(.system(size: 16)) + .foregroundStyle(.gray) + .frame(width: 34, height: 34) + } + + // Sessions / Chat / Cron pill toggle + TabPillToggle(selectedTab: $selectedTab) + + // Debug button Button { onDebugTapped?() } label: { Image(systemName: "ant") .font(.system(size: 16)) .foregroundStyle(.gray) + .frame(width: 34, height: 34) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - Rectangle() - .fill(Color(.systemGray5)) - .frame(height: 0.5) + .padding(.horizontal, 12) + .padding(.vertical, 10) } .background { - Color.white.opacity(0.75) - .background(.thinMaterial) + Rectangle() + .fill(.ultraThinMaterial) .ignoresSafeArea(edges: .top) } } } + +// MARK: - Compact Pill Toggle + +struct TabPillToggle: View { + @Binding var selectedTab: Tab + + var body: some View { + HStack(spacing: 0) { + tabButton(tab: .sessions, icon: "tray.full") + tabButton(tab: .chat, icon: "bubble.left.and.bubble.right") + tabButton(tab: .cron, icon: "clock.arrow.circlepath") + } + .padding(3) + .background( + Capsule() + .fill(.ultraThinMaterial) + ) + } + + @ViewBuilder + private func tabButton(tab: Tab, icon: String) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } label: { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(selectedTab == tab ? .white : .gray) + .frame(width: 30, height: 30) + .background( + selectedTab == tab + ? Capsule().fill(Color.blue) + : Capsule().fill(Color.clear) + ) + } + .buttonStyle(.plain) + } +} diff --git a/Chowder/Chowder/Views/ChatView.swift b/Chowder/Chowder/Views/ChatView.swift index 60f49ad..24b562d 100644 --- a/Chowder/Chowder/Views/ChatView.swift +++ b/Chowder/Chowder/Views/ChatView.swift @@ -1,10 +1,11 @@ import SwiftUI struct ChatView: View { - @State private var viewModel = ChatViewModel() + @Bindable var viewModel: ChatViewModel + @Binding var selectedTab: Tab @State private var isAtBottom = true + @State private var showSearch = false @FocusState private var isInputFocused: Bool - @Environment(\.scenePhase) private var scenePhase var body: some View { VStack(spacing: 0) { @@ -53,6 +54,16 @@ struct ChatView: View { .transition(.opacity.animation(.easeOut(duration: 0.15))) } + // Approval cards — shown inline when agent needs user approval + ForEach(viewModel.pendingApprovals, id: \.id) { request in + ApprovalCardView( + request: request, + onApprove: { viewModel.handleApprovalResponse(id: request.id, approved: true) }, + onDeny: { viewModel.handleApprovalResponse(id: request.id, approved: false) } + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + // Thinking shimmer — shown while the agent is working if let activity = viewModel.currentActivity, !activity.currentLabel.isEmpty { @@ -77,6 +88,7 @@ struct ChatView: View { .padding(.horizontal, 16) .padding(.bottom, 16) } + .defaultScrollAnchor(.bottom) .overlay(alignment: .bottom) { if !isAtBottom { Button { @@ -98,12 +110,75 @@ struct ChatView: View { .transition(.scale(scale: 0.5).combined(with: .opacity)) } } - // -- Auto-scroll handlers removed; add back as needed -- + .onChange(of: viewModel.messages.count) { + // Scroll to bottom when new messages arrive + if isAtBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + } + .onChange(of: viewModel.messages.last?.content) { + // Auto-scroll as streaming message content grows + if isAtBottom { + proxy.scrollTo("bottom", anchor: .bottom) + } + } .scrollDismissesKeyboard(.interactively) + // Search overlay + .overlay(alignment: .top) { + if showSearch { + MessageSearchView( + messages: viewModel.messages, + isPresented: $showSearch, + onResultTapped: { messageId in + showSearch = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + proxy.scrollTo(messageId, anchor: .center) + } + } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } } // Input bar - HStack(spacing: 12) { + HStack(spacing: 8) { + // Image picker button + Button { + viewModel.showImagePicker = true + } label: { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 20)) + .foregroundStyle(.gray) + } + + // Image thumbnail preview (if image is staged) + if let stagedImage = viewModel.stagedImage { + ZStack(alignment: .topTrailing) { + Image(uiImage: stagedImage) + .resizable() + .scaledToFill() + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button { + viewModel.stagedImage = nil + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.white) + .background(Circle().fill(Color.black.opacity(0.6))) + } + .offset(x: 4, y: -4) + } + } + TextField("Message...", text: $viewModel.inputText, axis: .vertical) .focused($isInputFocused) .lineLimit(1...5) @@ -112,6 +187,15 @@ struct ChatView: View { .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 20)) + // Mic button for voice input + Button { + viewModel.toggleVoiceInput() + } label: { + Image(systemName: viewModel.isListening ? "mic.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(viewModel.isListening ? .red : .gray) + } + Button { isInputFocused = false viewModel.send() @@ -119,12 +203,12 @@ struct ChatView: View { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 32)) .foregroundStyle( - viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading - ? Color(.systemGray4) - : Color.blue + viewModel.canSend + ? Color.blue + : Color(.systemGray4) ) } - .disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading) + .disabled(!viewModel.canSend) } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -134,28 +218,35 @@ struct ChatView: View { ChatHeaderView( botName: viewModel.botName, isOnline: viewModel.isConnected, - taskSummary: viewModel.currentTaskSummary, + avatarImage: viewModel.avatarImage, + selectedTab: $selectedTab, onSettingsTapped: { viewModel.showSettings = true }, - onDebugTapped: { viewModel.showDebugLog = true } + onDebugTapped: { viewModel.showDebugLog = true }, + onSearchTapped: { + withAnimation(.easeInOut(duration: 0.25)) { + showSearch.toggle() + } + }, + isSpeakerEnabled: viewModel.isSpeakerEnabled, + onSpeakerToggle: { viewModel.toggleSpeaker() } ) } .navigationBarHidden(true) - .onAppear { - viewModel.connect() - } - .onChange(of: scenePhase) { oldPhase, newPhase in - if oldPhase == .background && newPhase == .active { - viewModel.reconnect() - } - } .sheet(isPresented: $viewModel.showSettings) { SettingsView( currentIdentity: viewModel.botIdentity, currentProfile: viewModel.userProfile, + currentAvatar: viewModel.avatarImage, isConnected: viewModel.isConnected, onSave: { identity, profile in viewModel.saveWorkspaceData(identity: identity, profile: profile) }, + onSaveAvatar: { image in + viewModel.saveManualAvatar(image) + }, + onDeleteAvatar: { + viewModel.deleteAvatar() + }, onSaveConnection: { viewModel.reconnect() }, @@ -198,9 +289,14 @@ struct ChatView: View { } } } + .sheet(isPresented: $viewModel.showImagePicker) { + ImagePickerView { image in + viewModel.stagedImage = image + } + } } } #Preview { - ChatView() + ChatView(viewModel: ChatViewModel(), selectedTab: .constant(.chat)) } diff --git a/Chowder/Chowder/Views/CronJobsView.swift b/Chowder/Chowder/Views/CronJobsView.swift new file mode 100644 index 0000000..d5e45e5 --- /dev/null +++ b/Chowder/Chowder/Views/CronJobsView.swift @@ -0,0 +1,155 @@ +import SwiftUI + +struct CronJobsView: View { + var chatService: ChatService? + var isConnected: Bool + @Binding var selectedTab: Tab + var viewModel: ChatViewModel + + @State private var jobs: [CronJob] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Reuse the same header with the pill toggle + ChatHeaderView( + botName: viewModel.botName, + isOnline: viewModel.isConnected, + avatarImage: viewModel.avatarImage, + selectedTab: $selectedTab, + onSettingsTapped: { viewModel.showSettings = true }, + onDebugTapped: { viewModel.showDebugLog = true } + ) + + Group { + if !isConnected { + ContentUnavailableView( + "Not Connected", + systemImage: "wifi.slash", + description: Text("Connect to the gateway to view cron jobs.") + ) + } else if isLoading && jobs.isEmpty { + ProgressView("Loading cron jobs...") + } else if let error = errorMessage, jobs.isEmpty { + ContentUnavailableView( + "Error", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if jobs.isEmpty { + ContentUnavailableView( + "No Cron Jobs", + systemImage: "clock.arrow.circlepath", + description: Text("No cron jobs configured on the gateway.") + ) + } else { + List { + ForEach(jobs) { job in + NavigationLink(destination: CronJobDetailView(job: job, chatService: chatService)) { + CronJobRow(job: job) + } + } + } + .refreshable { + await fetchJobs() + } + } + } + } + .navigationBarHidden(true) + .onAppear { + if jobs.isEmpty { + Task { await fetchJobs() } + } + } + .onChange(of: isConnected) { _, connected in + if connected && jobs.isEmpty { + Task { await fetchJobs() } + } + } + } + } + + @MainActor + private func fetchJobs() async { + guard let chatService, isConnected else { return } + isLoading = true + errorMessage = nil + + await withCheckedContinuation { continuation in + chatService.fetchCronJobs { ok, rawJobs in + if ok, let rawJobs { + self.jobs = rawJobs.compactMap { CronJob.from(dict: $0) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } else if !ok { + self.errorMessage = "Failed to fetch cron jobs." + } + self.isLoading = false + continuation.resume() + } + } + } +} + +// MARK: - Row + +private struct CronJobRow: View { + let job: CronJob + + var body: some View { + HStack(spacing: 12) { + // Status indicator + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(job.name) + .font(.system(size: 16, weight: .medium)) + .lineLimit(1) + + if !job.enabled { + Text("DISABLED") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(.systemGray5)) + .clipShape(Capsule()) + } + } + + Text(job.schedule.humanReadable) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + Spacer() + + // Next run + if let nextMs = job.state.nextRunAtMs { + VStack(alignment: .trailing, spacing: 2) { + Text("Next") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + Text(CronJob.relativeTime(fromMs: nextMs)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private var statusColor: Color { + guard job.enabled else { return Color(.systemGray4) } + switch job.state.lastRunStatus { + case "ok": return .green + case "error": return .red + default: return Color(.systemGray4) + } + } +} diff --git a/Chowder/Chowder/Views/ImagePickerView.swift b/Chowder/Chowder/Views/ImagePickerView.swift new file mode 100644 index 0000000..d531007 --- /dev/null +++ b/Chowder/Chowder/Views/ImagePickerView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import PhotosUI + +struct ImagePickerView: View { + @Environment(\.dismiss) private var dismiss + @State private var selectedItem: PhotosPickerItem? + @State private var showCamera = false + var onImageSelected: (UIImage) -> Void + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + // Camera option + Button { + showCamera = true + } label: { + Label("Take Photo", systemImage: "camera.fill") + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // Photo library picker + PhotosPicker(selection: $selectedItem, matching: .images) { + Label("Choose from Library", systemImage: "photo.on.rectangle") + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + Spacer() + } + .padding(24) + .navigationTitle("Add Image") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .onChange(of: selectedItem) { _, newItem in + guard let newItem else { return } + Task { + if let data = try? await newItem.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + let compressed = compressImage(uiImage) + onImageSelected(compressed) + dismiss() + } + } + } + .fullScreenCover(isPresented: $showCamera) { + CameraView { image in + let compressed = compressImage(image) + onImageSelected(compressed) + dismiss() + } + .ignoresSafeArea() + } + } + } + + /// Compress image to max 1MB JPEG at 0.7 quality, downscaling if needed. + private func compressImage(_ image: UIImage) -> UIImage { + let maxDimension: CGFloat = 1920 + var resized = image + + // Downscale if too large + if image.size.width > maxDimension || image.size.height > maxDimension { + let scale = min(maxDimension / image.size.width, maxDimension / image.size.height) + let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } + + // Compress to JPEG + if let data = resized.jpegData(compressionQuality: 0.7), + let compressed = UIImage(data: data) { + return compressed + } + return resized + } +} + +// MARK: - Camera View (UIKit wrapper) + +struct CameraView: UIViewControllerRepresentable { + var onImageCaptured: (UIImage) -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onImageCaptured: onImageCaptured) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let onImageCaptured: (UIImage) -> Void + + init(onImageCaptured: @escaping (UIImage) -> Void) { + self.onImageCaptured = onImageCaptured + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + onImageCaptured(image) + } + picker.dismiss(animated: true) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + } +} diff --git a/Chowder/Chowder/Views/MainTabView.swift b/Chowder/Chowder/Views/MainTabView.swift new file mode 100644 index 0000000..7693f36 --- /dev/null +++ b/Chowder/Chowder/Views/MainTabView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +enum Tab: String, CaseIterable { + case sessions, chat, cron +} + +struct MainTabView: View { + @State private var viewModel = ChatViewModel() + @State private var selectedTab: Tab = .chat + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + ZStack { + SessionListView(viewModel: viewModel, selectedTab: $selectedTab) + .opacity(selectedTab == .sessions ? 1 : 0) + .allowsHitTesting(selectedTab == .sessions) + + ChatView(viewModel: viewModel, selectedTab: $selectedTab) + .opacity(selectedTab == .chat ? 1 : 0) + .allowsHitTesting(selectedTab == .chat) + + CronJobsView( + chatService: viewModel.exposedChatService, + isConnected: viewModel.isConnected, + selectedTab: $selectedTab, + viewModel: viewModel + ) + .opacity(selectedTab == .cron ? 1 : 0) + .allowsHitTesting(selectedTab == .cron) + } + .onAppear { + viewModel.connect() + } + .onChange(of: scenePhase) { oldPhase, newPhase in + if newPhase == .background { + viewModel.didEnterBackground() + } else if newPhase == .active && oldPhase != .active { + viewModel.didReturnToForeground() + if oldPhase == .background { + viewModel.reconnect() + } + } + } + .alert("Device Not Paired", isPresented: $viewModel.showNotPairedAlert) { + Button("Open Gateway") { + let urlString = ConnectionConfig().gatewayURL + .replacingOccurrences(of: "ws://", with: "http://") + .replacingOccurrences(of: "wss://", with: "https://") + if let url = URL(string: urlString) { + UIApplication.shared.open(url) + } + } + Button("Open Settings") { + viewModel.showSettings = true + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This device's identity has changed (e.g. after reinstall). Please re-approve it in your gateway's device management, then reconnect.") + } + } +} diff --git a/Chowder/Chowder/Views/MessageBubbleView.swift b/Chowder/Chowder/Views/MessageBubbleView.swift index 1fcdfdf..47b6002 100644 --- a/Chowder/Chowder/Views/MessageBubbleView.swift +++ b/Chowder/Chowder/Views/MessageBubbleView.swift @@ -9,24 +9,39 @@ struct MessageBubbleView: View { Spacer(minLength: 60) } - Group { - if message.role == .assistant { - MarkdownContentView(message.content, foregroundColor: Color(.label)) - .font(.system(size: 17)) - .textSelection(.enabled) - } else { - Text(message.content) - .font(.system(size: 17, weight: .regular, design: .default)) - .foregroundStyle(.white) + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { + // Image attachment (if any) + if let imageData = message.imageData, + let uiImage = UIImage(data: imageData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 220, maxHeight: 220) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + + // Text content + if !message.content.isEmpty { + Group { + if message.role == .assistant { + MarkdownContentView(message.content, foregroundColor: Color(.label)) + .font(.system(size: 17)) + .textSelection(.enabled) + } else { + Text(message.content) + .font(.system(size: 17, weight: .regular, design: .default)) + .foregroundStyle(.white) + } + } + .padding(message.role == .user ? 12 : 0) + .background( + message.role == .user + ? RoundedRectangle(cornerRadius: 18) + .fill(Color.blue) + : nil + ) } } - .padding(message.role == .user ? 12 : 0) - .background( - message.role == .user - ? RoundedRectangle(cornerRadius: 18) - .fill(Color.blue) - : nil - ) .contextMenu { Button("Copy") { UIPasteboard.general.string = message.content diff --git a/Chowder/Chowder/Views/MessageSearchView.swift b/Chowder/Chowder/Views/MessageSearchView.swift new file mode 100644 index 0000000..4d0e4e0 --- /dev/null +++ b/Chowder/Chowder/Views/MessageSearchView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import Combine + +struct MessageSearchView: View { + let messages: [Message] + @Binding var isPresented: Bool + var onResultTapped: (UUID) -> Void + + @State private var query = "" + @State private var results: [Message] = [] + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 0) { + // Search bar + HStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14)) + .foregroundStyle(.gray) + + TextField("Search messages...", text: $query) + .font(.system(size: 15)) + .focused($isFocused) + .autocorrectionDisabled() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Button("Cancel") { + isPresented = false + } + .font(.system(size: 15)) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + + // Results + if !query.isEmpty { + if results.isEmpty { + VStack(spacing: 8) { + Text("No results") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + .background(Color(.systemBackground).opacity(0.95)) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(results) { message in + Button { + onResultTapped(message.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(message.role == .user ? "You" : "Assistant") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(message.role == .user ? .blue : .green) + + Text(message.timestamp, style: .relative) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + + Text(highlightedSnippet(message.content, query: query)) + .font(.system(size: 14)) + .foregroundStyle(.primary) + .lineLimit(3) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + Divider().padding(.leading, 16) + } + } + } + .background(Color(.systemBackground).opacity(0.95)) + .frame(maxHeight: 300) + } + } + } + .onAppear { + isFocused = true + } + .onChange(of: query) { + performSearch() + } + } + + /// Debounced search — filters messages matching the query (case-insensitive). + private func performSearch() { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + results = [] + return + } + results = messages.filter { msg in + msg.content.lowercased().contains(trimmed) + } + } + + /// Build an attributed snippet with the query highlighted. + private func highlightedSnippet(_ content: String, query: String) -> AttributedString { + let snippet = String(content.prefix(200)) + var attributed = AttributedString(snippet) + + let lowSnippet = snippet.lowercased() + let lowQuery = query.lowercased() + + var searchStart = lowSnippet.startIndex + while let range = lowSnippet.range(of: lowQuery, range: searchStart..