diff --git a/README.md b/README.md index 87f251cd4..51823969a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,12 @@ clawdis relay # Start listening | `clawdis status` | Show recent messages | | `clawdis heartbeat` | Trigger a heartbeat | +### Sessions, surfaces, and WebChat + +- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:`. +- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/.jsonl`, so desktop view mirrors WhatsApp/Telegram turns. +- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically. + ## Credits - **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index 1cf3f5e49..a86a093c2 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -90,6 +90,11 @@ final class AppState: ObservableObject { didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } } + @Published var isWorking: Bool = false + @Published var earBoostActive: Bool = false + + private var earBoostTask: Task? = nil + init() { self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? "" @@ -106,6 +111,19 @@ final class AppState: ObservableObject { self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] } + + func triggerVoiceEars(ttl: TimeInterval = 5) { + self.earBoostTask?.cancel() + self.earBoostActive = true + self.earBoostTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) + await MainActor.run { [weak self] in self?.earBoostActive = false } + } + } + + func setWorking(_ working: Bool) { + self.isWorking = working + } } @MainActor @@ -578,7 +596,12 @@ struct ClawdisApp: App { } var body: some Scene { - MenuBarExtra { MenuContent(state: self.state) } label: { CritterStatusLabel(isPaused: self.state.isPaused) } + MenuBarExtra { MenuContent(state: self.state) } label: { + CritterStatusLabel( + isPaused: self.state.isPaused, + isWorking: self.state.isWorking, + earBoostActive: self.state.earBoostActive) + } .menuBarExtraStyle(.menu) .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in self.statusItem = item @@ -632,19 +655,19 @@ private struct MenuContent: View { } private func primarySessionKey() -> String { - // Prefer the most recently updated session from the store; fall back to default + // Prefer canonical main session; fall back to most recent. let storePath = SessionLoader.defaultStorePath if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)), let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) { + if decoded.keys.contains("main") { return "main" } + let sorted = decoded.sorted { a, b -> Bool in let lhs = a.value.updatedAt ?? 0 let rhs = b.value.updatedAt ?? 0 return lhs > rhs } - if let first = sorted.first { - return first.key - } + if let first = sorted.first { return first.key } } return "+1003" } @@ -652,6 +675,8 @@ private struct MenuContent: View { private struct CritterStatusLabel: View { var isPaused: Bool + var isWorking: Bool + var earBoostActive: Bool @State private var blinkAmount: CGFloat = 0 @State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5)) @@ -672,8 +697,9 @@ private struct CritterStatusLabel: View { } else { Image(nsImage: CritterIconRenderer.makeIcon( blink: self.blinkAmount, - legWiggle: self.legWiggle, - earWiggle: self.earWiggle)) + legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0), + earWiggle: self.earWiggle, + earScale: self.earBoostActive ? 1.9 : 1.0)) .frame(width: 18, height: 16) .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) .offset(x: self.wiggleOffset) @@ -697,6 +723,10 @@ private struct CritterStatusLabel: View { self.wiggleEars() self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) } + + if self.isWorking { + self.scurry() + } } .onChange(of: self.isPaused) { _, _ in self.resetMotion() } } @@ -743,6 +773,20 @@ private struct CritterStatusLabel: View { } } + private func scurry() { + let target = CGFloat.random(in: 0.7...1.0) + withAnimation(.easeInOut(duration: 0.12)) { + self.legWiggle = target + self.wiggleOffset = CGFloat.random(in: -0.6...0.6) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.easeOut(duration: 0.16)) { + self.legWiggle = 0.25 + self.wiggleOffset = 0 + } + } + } + private func wiggleEars() { let target = CGFloat.random(in: -1.2...1.2) withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { @@ -757,7 +801,12 @@ private struct CritterStatusLabel: View { enum CritterIconRenderer { private static let size = NSSize(width: 18, height: 16) - static func makeIcon(blink: CGFloat, legWiggle: CGFloat = 0, earWiggle: CGFloat = 0) -> NSImage { + static func makeIcon( + blink: CGFloat, + legWiggle: CGFloat = 0, + earWiggle: CGFloat = 0, + earScale: CGFloat = 1 + ) -> NSImage { let image = NSImage(size: size) image.lockFocus() defer { image.unlockFocus() } @@ -774,7 +823,7 @@ enum CritterIconRenderer { let bodyCorner = w * 0.09 let earW = w * 0.22 - let earH = bodyH * 0.66 * (1 - 0.08 * abs(earWiggle)) + let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle)) let earCorner = earW * 0.24 let legW = w * 0.11 @@ -2009,6 +2058,7 @@ final class VoiceWakeTester { { if matched, !text.isEmpty { self.stop() + AppStateStore.shared.triggerVoiceEars() onUpdate(.detected(text)) return } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 2347184cd..4b8130091 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -1,13 +1,18 @@ import AppKit import Foundation +import OSLog import WebKit -final class WebChatWindowController: NSWindowController, WKScriptMessageHandler { +private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") + +final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, WKNavigationDelegate { private let webView: WKWebView private let sessionKey: String + private let initialMessagesJSON: String init(sessionKey: String) { self.sessionKey = sessionKey + self.initialMessagesJSON = WebChatWindowController.loadInitialMessagesJSON(for: sessionKey) let config = WKWebViewConfiguration() let contentController = WKUserContentController() @@ -40,6 +45,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) }); } catch (_) {} }; + const __origConsoleLog = console.log; + console.log = function(...args) { + try { window.__clawdisLog(args.join(' ')); } catch (_) {} + __origConsoleLog.apply(console, args); + }; """ let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true) contentController.addUserScript(userScript) @@ -53,6 +63,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler window.title = "Clawd Web Chat" window.contentView = self.webView super.init(window: window) + self.webView.navigationDelegate = self contentController.add(self, name: "clawdis") self.loadPage() } @@ -61,6 +72,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler required init?(coder: NSCoder) { fatalError() } private func loadPage() { + let messagesJSON = self.initialMessagesJSON.replacingOccurrences(of: "", with: "<\\/script>") guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil) else { NSLog("WebChat resources missing") return @@ -119,6 +131,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler