feat: unify main session and icon cues
This commit is contained in:
@@ -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<Void, Never>? = 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
|
||||
}
|
||||
|
||||
@@ -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: "</script>", 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
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
const initialMessages = \(messagesJSON);
|
||||
const status = (msg) => {
|
||||
console.log(msg);
|
||||
window.__clawdisLog(msg);
|
||||
@@ -169,7 +182,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
||||
systemPrompt: 'You are Clawd (primary session).',
|
||||
model: getModel('anthropic', 'claude-opus-4-5'),
|
||||
thinkingLevel: 'off',
|
||||
messages: []
|
||||
messages: initialMessages
|
||||
},
|
||||
transport: new NativeTransport()
|
||||
});
|
||||
@@ -212,6 +225,16 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
||||
self.webView.loadHTMLString(html, baseURL: webChatURL)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
webView.evaluateJavaScript("document.body.innerText") { result, error in
|
||||
if let error {
|
||||
webChatLogger.error("eval error: \(error.localizedDescription, privacy: .public)")
|
||||
} else if let text = result as? String {
|
||||
webChatLogger.debug("body text snapshot: \(String(text.prefix(200)), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard let body = message.body as? [String: Any],
|
||||
let id = body["id"] as? String,
|
||||
@@ -219,7 +242,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
||||
else { return }
|
||||
|
||||
if id == "log", let log = body["log"] as? String {
|
||||
NSLog("WebChat JS: %@", log)
|
||||
webChatLogger.debug("JS: \(log, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -245,6 +268,8 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
||||
}
|
||||
|
||||
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
|
||||
await MainActor.run { AppStateStore.shared.setWorking(true) }
|
||||
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
|
||||
let data: Data
|
||||
do {
|
||||
data = try await Task.detached(priority: .utility) { () -> Data in
|
||||
@@ -280,6 +305,48 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
||||
}
|
||||
return (String(data: data, encoding: .utf8), nil)
|
||||
}
|
||||
|
||||
private static func loadInitialMessagesJSON(for sessionKey: String) -> String {
|
||||
guard let sessionId = self.sessionId(for: sessionKey) else { return "[]" }
|
||||
let path = self.expand("~/.clawdis/sessions/\(sessionId).jsonl")
|
||||
guard FileManager.default.fileExists(atPath: path) else { return "[]" }
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return "[]" }
|
||||
|
||||
var messages: [[String: Any]] = []
|
||||
for line in content.split(whereSeparator: { $0.isNewline }) {
|
||||
guard let data = String(line).data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { continue }
|
||||
let message = (obj["message"] as? [String: Any]) ?? obj
|
||||
guard let role = message["role"] as? String,
|
||||
["user", "assistant", "system"].contains(role)
|
||||
else { continue }
|
||||
|
||||
var contentPayload = message["content"] as? [[String: Any]]
|
||||
if contentPayload == nil, let text = message["text"] as? String {
|
||||
contentPayload = [["type": "text", "text": text]]
|
||||
}
|
||||
guard let finalContent = contentPayload else { continue }
|
||||
messages.append(["role": role, "content": finalContent])
|
||||
}
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: messages, options: []) else {
|
||||
return "[]"
|
||||
}
|
||||
return String(data: data, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
private static func sessionId(for key: String) -> String? {
|
||||
let storePath = self.expand("~/.clawdis/sessions/sessions.json")
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)) else { return nil }
|
||||
guard let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
guard let entry = decoded[key] as? [String: Any] else { return nil }
|
||||
return entry["sessionId"] as? String
|
||||
}
|
||||
|
||||
private static func expand(_ path: String) -> String {
|
||||
(path as NSString).expandingTildeInPath
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user