diff --git a/frontend/ios/App/App.xcodeproj/project.pbxproj b/frontend/ios/App/App.xcodeproj/project.pbxproj index 0f88a83f..121ef7b1 100644 --- a/frontend/ios/App/App.xcodeproj/project.pbxproj +++ b/frontend/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1F97793D2FB86A7A00B87829 /* WatchAuthBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */; }; + 1F97793E2FB86A8000B87829 /* WatchRelayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */; }; 2C807BBEF27900A4B4C713F3 /* VoiceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E438A1E5CA844963819792C4 /* VoiceService.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 30271F81931105049A8D0BCA /* MitzoShared in Frameworks */ = {isa = PBXBuildFile; productRef = 2994CBB574BE2544F671F9BF /* MitzoShared */; }; @@ -20,19 +22,20 @@ 51C82AC7CC9D0CD9BB2B8A2D /* MitzoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729F6EAA7E6281CC8E027E58 /* MitzoWatchApp.swift */; }; 87D5F3E4B2B79830BDC6F3CA /* VoiceInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */; }; 91D36789BA83D98F79CA5EA1 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */; }; - 9A8EE45055EB9BF5AB711065 /* PermissionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB84607485C9AAA4D695F477 /* PermissionBanner.swift */; }; B87D83BB43FD8D7A331D5C16 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A60979EA195F633EE664E716 /* LoginView.swift */; }; BDF94F63DBEDEAABE3DBA4B8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6B3AF931A2A562E89BCCA0 /* ChatView.swift */; }; E7C8020332FACEAD32C32377 /* MitzoShared in Frameworks */ = {isa = PBXBuildFile; productRef = A997DA8238CC43203EE0AD73 /* MitzoShared */; }; EB52F01B4D4DE944429DFFB7 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3543D568BAEA16F3464C2D /* SessionListView.swift */; }; EBDB8718D00B3CAC47EFD6EC /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB2544C9BF33F98A575F8F9F /* AppState.swift */; }; + F1A2B3C4D5E6F70800000001 /* ComposeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRelayCoordinator.swift; sourceTree = ""; }; + 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAuthBridge.swift; sourceTree = ""; }; 264FB5577C8B9098BE816A36 /* MitzoWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MitzoWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; - 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -50,6 +53,7 @@ AB84607485C9AAA4D695F477 /* PermissionBanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PermissionBanner.swift; sourceTree = ""; }; CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceInputBar.swift; sourceTree = ""; }; + F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeSheet.swift; sourceTree = ""; }; E438A1E5CA844963819792C4 /* VoiceService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceService.swift; sourceTree = ""; }; FA1E96663E02190E82402696 /* MitzoWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MitzoWatch.entitlements; sourceTree = ""; }; FB2544C9BF33F98A575F8F9F /* AppState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; @@ -77,7 +81,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( @@ -104,6 +107,8 @@ A1B2C3D40000000000000001 /* App.entitlements */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 1F97793B2FB86A5700B87829 /* WatchRelayCoordinator.swift */, + 1F97793C2FB86A7A00B87829 /* WatchAuthBridge.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -121,7 +126,6 @@ CE85EF8992F04F5159962BE4 /* ChatViewModel.swift */, E438A1E5CA844963819792C4 /* VoiceService.swift */, ); - name = Services; path = Services; sourceTree = ""; }; @@ -145,9 +149,9 @@ AB84607485C9AAA4D695F477 /* PermissionBanner.swift */, CF9D046593F17D7DF6E6056B /* VoiceInputBar.swift */, FF6B3AF931A2A562E89BCCA0 /* ChatView.swift */, + F1A2B3C4D5E6F70800000002 /* ComposeSheet.swift */, 9A3543D568BAEA16F3464C2D /* SessionListView.swift */, ); - name = Views; path = Views; sourceTree = ""; }; @@ -233,7 +237,7 @@ mainGroup = 504EC2FB1FED79650016851F; packageReferences = ( D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, - F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */, + F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */, ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; @@ -273,6 +277,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1F97793D2FB86A7A00B87829 /* WatchAuthBridge.swift in Sources */, + 1F97793E2FB86A8000B87829 /* WatchRelayCoordinator.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -289,6 +295,7 @@ 9A8EE45055EB9BF5AB711065 /* PermissionBanner.swift in Sources */, 87D5F3E4B2B79830BDC6F3CA /* VoiceInputBar.swift in Sources */, BDF94F63DBEDEAABE3DBA4B8 /* ChatView.swift in Sources */, + F1A2B3C4D5E6F70800000001 /* ComposeSheet.swift in Sources */, EB52F01B4D4DE944429DFFB7 /* SessionListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -565,7 +572,7 @@ isa = XCLocalSwiftPackageReference; relativePath = "CapApp-SPM"; }; - F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */ = { + F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../MitzoShared; }; @@ -574,7 +581,7 @@ /* Begin XCSwiftPackageProductDependency section */ 2994CBB574BE2544F671F9BF /* MitzoShared */ = { isa = XCSwiftPackageProductDependency; - package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */; + package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */; productName = MitzoShared; }; 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { @@ -584,7 +591,7 @@ }; A997DA8238CC43203EE0AD73 /* MitzoShared */ = { isa = XCSwiftPackageProductDependency; - package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "MitzoShared" */; + package = F0C38DA10F17B4BECD2F4E09 /* XCLocalSwiftPackageReference "../MitzoShared" */; productName = MitzoShared; }; /* End XCSwiftPackageProductDependency section */ diff --git a/frontend/ios/App/App/App.entitlements b/frontend/ios/App/App/App.entitlements index 903def2a..ac50cce5 100644 --- a/frontend/ios/App/App/App.entitlements +++ b/frontend/ios/App/App/App.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)com.mitzo.app + diff --git a/frontend/ios/App/App/AppDelegate.swift b/frontend/ios/App/App/AppDelegate.swift index c3cd83b5..17e3f588 100644 --- a/frontend/ios/App/App/AppDelegate.swift +++ b/frontend/ios/App/App/AppDelegate.swift @@ -5,9 +5,10 @@ import Capacitor class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private let watchRelay = WatchRelayCoordinator() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + watchRelay.start() return true } @@ -17,12 +18,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + watchRelay.suspend() } func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + watchRelay.reconnect() } func applicationDidBecomeActive(_ application: UIApplication) { @@ -46,4 +46,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + } diff --git a/frontend/ios/App/App/Info.plist b/frontend/ios/App/App/Info.plist index b4014b18..837390e0 100644 --- a/frontend/ios/App/App/Info.plist +++ b/frontend/ios/App/App/Info.plist @@ -2,7 +2,9 @@ - CAPACITOR_DEBUG + AppIdentifierPrefix + $(AppIdentifierPrefix) + CAPACITOR_DEBUG $(CAPACITOR_DEBUG) CFBundleDevelopmentRegion en diff --git a/frontend/ios/App/App/WatchAuthBridge.swift b/frontend/ios/App/App/WatchAuthBridge.swift new file mode 100644 index 00000000..199bf537 --- /dev/null +++ b/frontend/ios/App/App/WatchAuthBridge.swift @@ -0,0 +1,45 @@ +// Capacitor plugin that bridges web auth tokens into the native shared Keychain. +// When the web app logs in, it calls WatchAuthBridge.saveToken() so the +// watch can read the JWT from the shared Keychain access group. + +import Capacitor +import MitzoShared + +@objc(WatchAuthBridge) +public class WatchAuthBridge: CAPPlugin, CAPBridgedPlugin { + public let identifier = "WatchAuthBridge" + public let jsName = "WatchAuthBridge" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "saveToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearToken", returnType: CAPPluginReturnPromise), + ] + + private let authManager = AuthManager() + + @objc func saveToken(_ call: CAPPluginCall) { + guard let token = call.getString("token") else { + call.reject("Missing token") + return + } + + Task { + do { + try await authManager.saveToken(token) + call.resolve() + } catch { + call.reject("Failed to save token: \(error.localizedDescription)") + } + } + } + + @objc func clearToken(_ call: CAPPluginCall) { + Task { + do { + try await authManager.clearToken() + call.resolve() + } catch { + call.reject("Failed to clear token: \(error.localizedDescription)") + } + } + } +} diff --git a/frontend/ios/App/App/WatchRelayCoordinator.swift b/frontend/ios/App/App/WatchRelayCoordinator.swift new file mode 100644 index 00000000..72f09abe --- /dev/null +++ b/frontend/ios/App/App/WatchRelayCoordinator.swift @@ -0,0 +1,69 @@ +// Coordinates native WS connection + WatchRelay for Apple Watch communication. +// The Capacitor web layer has its own WS; this is a second native connection +// used exclusively by WatchRelayHost to bridge watch ↔ server messages. + +import UIKit +import MitzoShared + +final class WatchRelayCoordinator: @unchecked Sendable { + private let authManager = AuthManager() + private let watchRelay: WatchRelayHost + private var wsClient: MitzoWSClient? + private let lock = NSLock() + + private var serverURL: URL { + let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") + return URL(string: stored ?? "https://dimakis-mac.tail:3100")! + } + + init() { + watchRelay = WatchRelayHost(authManager: authManager) + // Activate WCSession immediately so watch can reach us even before login + watchRelay.activate(wsClient: nil, apiClient: nil) + } + + func start() { + Task { await connect() } + } + + func reconnect() { + Task { await connect() } + } + + func suspend() { + Task { + let client: MitzoWSClient? = lock.withLock { wsClient } + if let client { + let sessions = await client.getSuspendSessions() + if !sessions.isEmpty { + try? await client.suspend(sessions: sessions) + } + } + } + } + + private func connect() async { + guard let token = try? await authManager.getToken() else { return } + + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/ws/chat" + components.queryItems = [URLQueryItem(name: "token", value: token)] + + guard let wsURL = components.url else { return } + + let client = MitzoWSClient(url: wsURL) + lock.withLock { wsClient = client } + + let api = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + // Update relay with authenticated clients (WCSession already activated in init) + watchRelay.activate(wsClient: client, apiClient: api) + + let relay = watchRelay + await client.connect { event in + if case .message(let msg) = event { + relay.forwardToWatch(msg) + } + } + } +} diff --git a/frontend/ios/App/WatchRelayCoordinator.swift b/frontend/ios/App/WatchRelayCoordinator.swift new file mode 100644 index 00000000..a2cdb255 --- /dev/null +++ b/frontend/ios/App/WatchRelayCoordinator.swift @@ -0,0 +1,74 @@ +// Coordinates native WS connection + WatchRelay for Apple Watch communication. +// The Capacitor web layer has its own WS; this is a second native connection +// used exclusively by WatchRelayHost to bridge watch ↔ server messages. + +import UIKit +import MitzoShared + +final class WatchRelayCoordinator: @unchecked Sendable { + private let authManager = AuthManager() + private let watchRelay: WatchRelayHost + private var wsClient: MitzoWSClient? + private let lock = NSLock() + + private var serverURL: URL { + let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") + return URL(string: stored ?? "https://dimakis-mac.tail:3100")! + } + + init() { + watchRelay = WatchRelayHost(authManager: authManager) + } + + func start() { + // Activate WCSession unconditionally so the watch relay works + // even before auth. This lets the auth_token relay bootstrap + // the token, and list_sessions/get_messages return proper errors + // instead of silently hanging with no delegate. + let apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + watchRelay.activate(wsClient: nil, apiClient: apiClient) + + Task { await connectWS() } + } + + func reconnect() { + Task { await connectWS() } + } + + func suspend() { + Task { + let client: MitzoWSClient? = lock.withLock { wsClient } + if let client { + let sessions = await client.getSuspendSessions() + if !sessions.isEmpty { + try? await client.suspend(sessions: sessions) + } + } + } + } + + /// Connect the native WS (for forwarding server events to watch). + /// WCSession is already active from start() — this only adds WS. + private func connectWS() async { + guard let token = try? await authManager.getToken() else { return } + + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/ws/chat" + components.queryItems = [URLQueryItem(name: "token", value: token)] + + guard let wsURL = components.url else { return } + + let client = MitzoWSClient(url: wsURL) + let apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) + lock.withLock { wsClient = client } + watchRelay.activate(wsClient: client, apiClient: apiClient) + + let relay = watchRelay + await client.connect { event in + if case .message(let msg) = event { + relay.forwardToWatch(msg) + } + } + } +} diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift index db34ddf2..3c6db458 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Auth/AuthManager.swift @@ -13,8 +13,16 @@ public actor AuthManager { private let keychainService = "com.mitzo.app" private let keychainAccount = "jwt" - // Team ID prefix for shared Keychain access group (iOS + watchOS) - private let accessGroup = "Y4QGXHYSY3.com.mitzo.app" + // Shared Keychain access group (iOS + watchOS). + // AppIdentifierPrefix is injected by Xcode at build time — no hardcoded Team ID. + private let accessGroup: String = { + if let prefix = Bundle.main.infoDictionary?["AppIdentifierPrefix"] as? String { + return "\(prefix)com.mitzo.app" + } + // Fallback: read from entitlements at runtime isn't possible, + // so use the known group directly. This only fires in unit tests. + return "Y4QGXHYSY3.com.mitzo.app" + }() private var cachedToken: String? @@ -120,7 +128,7 @@ public actor AuthManager { let body = ["passphrase": passphrase] request.httpBody = try JSONEncoder().encode(body) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await tailscaleURLSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift index ac3a0324..f4844187 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoAPIClient.swift @@ -51,7 +51,7 @@ public actor MitzoAPIClient { let token = try await authManager.getToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await tailscaleURLSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift index 247943f9..c5eb9f8c 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/MitzoWSClient.swift @@ -108,8 +108,7 @@ public actor MitzoWSClient { state = .connecting eventHandler?(.stateChanged(.connecting)) - let session = URLSession(configuration: .default) - wsTask = session.webSocketTask(with: url) + wsTask = tailscaleURLSession.webSocketTask(with: url) wsTask?.resume() // Send hello diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift new file mode 100644 index 00000000..b1fa6fbb --- /dev/null +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/TailscaleTrust.swift @@ -0,0 +1,35 @@ +// URLSession that trusts Tailscale / self-signed certificates. +// Mitzo runs over Tailscale with HTTPS; the cert isn't in the +// system trust store, so URLSession rejects it by default. + +import Foundation + +/// URLSession delegate that accepts TLS certificates for *.ts.net +/// and localhost hosts (matching Capacitor's allowNavigation config). +public final class TailscaleTrustDelegate: NSObject, URLSessionDelegate, Sendable { + public static let shared = TailscaleTrustDelegate() + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + + let host = challenge.protectionSpace.host + if host.hasSuffix(".ts.net") || host == "localhost" || host.hasSuffix(".tail") { + return (.useCredential, URLCredential(trust: trust)) + } + + return (.performDefaultHandling, nil) + } +} + +/// Shared URLSession that trusts Tailscale hosts. +public let tailscaleURLSession: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + return URLSession(configuration: config, delegate: TailscaleTrustDelegate.shared, delegateQueue: nil) +}() diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift index a94102dc..6d81fe47 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/WatchRelay.swift @@ -26,17 +26,24 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { super.init() } - public func activate(wsClient: MitzoWSClient) { - state.setWSClient(wsClient) + public func activate(wsClient: MitzoWSClient? = nil, apiClient: MitzoAPIClient? = nil) { + if let wsClient { state.setWSClient(wsClient) } + if let apiClient { state.setAPIClient(apiClient) } - guard WCSession.isSupported() else { return } + guard WCSession.isSupported() else { + print("[WatchRelay] WCSession not supported") + return + } + print("[WatchRelay] Activating WCSession, delegate=\(WCSession.default.delegate == nil ? "nil" : "set")") WCSession.default.delegate = self WCSession.default.activate() } // MARK: - WCSessionDelegate - public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + print("[WatchRelay] Activation complete: state=\(activationState.rawValue), error=\(String(describing: error))") + } public func sessionDidBecomeInactive(_ session: WCSession) {} public func sessionDidDeactivate(_ session: WCSession) { @@ -44,34 +51,74 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { } public func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + print("[WatchRelay] Received message: \(message)") guard let type = message["_relay"] as? String else { + print("[WatchRelay] Missing _relay type, sending error") replyHandler(["error": "missing _relay type"]) return } + print("[WatchRelay] Relay type: \(type)") - // Extract values before crossing the Task isolation boundary + // Extract ALL values before crossing the Task isolation boundary // so we don't capture the non-Sendable [String: Any] dict. - let clientMsg: ClientMessage? + let clientMsg: UnsafeSendable if type == "send" { - clientMsg = try? decodeClientMessage(from: message) + clientMsg = UnsafeSendable(try? decodeClientMessage(from: message)) } else { - clientMsg = nil + clientMsg = UnsafeSendable(nil) } + let sessionId = message["sessionId"] as? String ?? "" let reply = UnsafeSendable(replyHandler) + let capturedState = state + let capturedAuthManager = authManager - Task { + Task { @Sendable in do { switch type { case "send": - guard let clientMsg else { + guard let msg = clientMsg.value else { reply.value(["error": "invalid message"]) return } - try await state.getWSClient()?.send(clientMsg) + try await capturedState.getWSClient()?.send(msg) reply.value(["ok": true]) + case "get_messages": + if let apiClient = capturedState.getAPIClient() { + do { + let messages: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) + let data = try JSONEncoder().encode(messages) + if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + reply.value(["_payload": arr]) + } else { + reply.value(["error": "encoding_failed"]) + } + } catch { + reply.value(["error": error.localizedDescription]) + } + } else { + reply.value(["error": "no_api_client"]) + } + + case "list_sessions": + if let apiClient = capturedState.getAPIClient() { + do { + let response = try await apiClient.getSessions() + let data = try JSONEncoder().encode(response) + if let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + reply.value(["_payload": dict]) + } else { + reply.value(["error": "encoding_failed"]) + } + } catch { + reply.value(["error": error.localizedDescription]) + } + } else { + reply.value(["error": "no_api_client"]) + } + case "auth_token": - if let token = try? await authManager.getToken() { + if let token = try? await capturedAuthManager.getToken() { reply.value(["token": token]) } else { reply.value(["error": "no_token"]) @@ -180,6 +227,7 @@ public final class WatchRelayHost: NSObject, WCSessionDelegate, Sendable { /// arrive on a background serial queue, so we need synchronization. private final class WatchRelayHostState: Sendable { private nonisolated(unsafe) var _wsClient: MitzoWSClient? + private nonisolated(unsafe) var _apiClient: MitzoAPIClient? private let lock = NSLock() func setWSClient(_ client: MitzoWSClient) { @@ -193,6 +241,18 @@ private final class WatchRelayHostState: Sendable { defer { lock.unlock() } return _wsClient } + + func setAPIClient(_ client: MitzoAPIClient?) { + lock.lock() + _apiClient = client + lock.unlock() + } + + func getAPIClient() -> MitzoAPIClient? { + lock.lock() + defer { lock.unlock() } + return _apiClient + } } enum RelayError: Error { @@ -245,6 +305,52 @@ public final class WatchRelayClient: NSObject, WCSessionDelegate, Sendable { } } + /// Request messages for a session from phone (phone calls server REST API) + public func requestMessages(sessionId: String) async throws -> [FinishedMessage] { + let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in + let cont = UnsafeSendable(continuation) + WCSession.default.sendMessage( + ["_relay": "get_messages", "sessionId": sessionId], + replyHandler: { @Sendable reply in cont.value.resume(returning: UnsafeSendable(reply).value) }, + errorHandler: { @Sendable error in cont.value.resume(throwing: error) } + ) + } + + if let errorMsg = reply["error"] as? String { + throw WatchRelayError.relayError(errorMsg) + } + + guard let payload = reply["_payload"] as? [[String: Any]] else { + throw WatchRelayError.invalidResponse + } + + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode([FinishedMessage].self, from: data) + } + + /// Request session list from phone (phone calls server REST API) + public func requestSessions() async throws -> SessionsResponse { + let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in + let cont = UnsafeSendable(continuation) + WCSession.default.sendMessage( + ["_relay": "list_sessions"], + replyHandler: { @Sendable reply in cont.value.resume(returning: UnsafeSendable(reply).value) }, + errorHandler: { @Sendable error in cont.value.resume(throwing: error) } + ) + } + + if let errorMsg = reply["error"] as? String { + throw WatchRelayError.relayError(errorMsg) + } + + guard let payload = reply["_payload"] as? [String: Any] else { + throw WatchRelayError.invalidResponse + } + + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode(SessionsResponse.self, from: data) + } + /// Request auth token from phone public func requestAuthToken() async throws -> String { let reply = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Error>) in @@ -301,5 +407,7 @@ private final class WatchRelayClientState: Sendable { enum WatchRelayError: Error { case noToken case notReachable + case invalidResponse + case relayError(String) } #endif diff --git a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift index 61a04a34..78b42dda 100644 --- a/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift +++ b/frontend/ios/MitzoShared/Sources/MitzoShared/Networking/YapperClient.swift @@ -27,7 +27,7 @@ public actor YapperClient { public func checkHealth() async throws -> Bool { let healthURL = baseURL.appendingPathComponent("/health") - let (data, _) = try await URLSession.shared.data(from: healthURL) + let (data, _) = try await tailscaleURLSession.data(from: healthURL) struct HealthResponse: Decodable { let status: String @@ -57,8 +57,7 @@ public actor YapperClient { throw YapperError.connectionFailed } - let session = URLSession(configuration: .default) - wsTask = session.webSocketTask(with: wsURL) + wsTask = tailscaleURLSession.webSocketTask(with: wsURL) wsTask?.resume() // Send format frame diff --git a/frontend/ios/MitzoWatch/Info.plist b/frontend/ios/MitzoWatch/Info.plist index 3b31c892..25ffa18b 100644 --- a/frontend/ios/MitzoWatch/Info.plist +++ b/frontend/ios/MitzoWatch/Info.plist @@ -24,5 +24,12 @@ Mitzo uses the microphone for voice input to send messages. WKApplication + WKCompanionAppBundleIdentifier + com.mitzo.app + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/frontend/ios/MitzoWatch/Services/AppState.swift b/frontend/ios/MitzoWatch/Services/AppState.swift index ca394106..f8e6b16d 100644 --- a/frontend/ios/MitzoWatch/Services/AppState.swift +++ b/frontend/ios/MitzoWatch/Services/AppState.swift @@ -18,18 +18,13 @@ final class AppState: ObservableObject { @Published var error: String? private let authManager = AuthManager() - private var wsClient: MitzoWSClient? - private var apiClient: MitzoAPIClient? private var activeChatVM: ChatViewModel? private let relayClient = WatchRelayClient() - private var reconnectAttempts = 0 - private var isReconnecting = false - private static let maxReconnectDelay: UInt64 = 30_000_000_000 // 30s - // Configurable via UserDefaults; defaults to Tailscale hostname + // Server URL used only for login (brief HTTP POST that sometimes works) var serverURL: URL { let stored = UserDefaults.standard.string(forKey: "mitzo_server_url") - return URL(string: stored ?? "https://mitzo.tail:3100")! + return URL(string: stored ?? "http://100.91.50.57:3101")! } init() { @@ -76,105 +71,51 @@ final class AppState: ObservableObject { } } - // MARK: - Connection (waterfall: direct → relay) + // MARK: - Connection (relay-only — direct to Tailscale IPs is blocked by NECP) func connect() async { - let directSuccess = await connectDirect() - if directSuccess { - reconnectAttempts = 0 - return + // watchOS cannot reach Tailscale IPs due to NECP policy on the + // iPhone's VPN extension. All traffic goes through the iPhone + // via WatchConnectivity relay. + + // WCSession activation is async — give it a moment if not ready yet + if !relayClient.isPhoneReachable { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2s } if relayClient.isPhoneReachable { connectionMode = .relay connectionState = .connected(connectionId: "relay") - reconnectAttempts = 0 await loadSessionsViaRelay() } else { connectionMode = .none - error = "Cannot reach server or iPhone" + connectionState = .disconnected + error = "iPhone not reachable — open Mitzo on your phone" + // Don't reconnect-loop. WCSession reachability changes will + // trigger a retry via the session delegate (future enhancement). } } - private func connectDirect() async -> Bool { - guard let token = try? await authManager.getToken() else { return false } - - var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! - components.scheme = components.scheme == "https" ? "wss" : "ws" - components.path = "/ws/chat" - components.queryItems = [URLQueryItem(name: "token", value: token)] - - guard let wsURL = components.url else { return false } - - let client = MitzoWSClient(url: wsURL) - wsClient = client - - apiClient = MitzoAPIClient(baseURL: serverURL, authManager: authManager) - - let connected = await withCheckedContinuation { continuation in - var resolved = false - - Task { - await client.connect { [weak self] event in - Task { @MainActor in - self?.handleWSEvent(event) - - if !resolved { - if case .stateChanged(.connected) = event { - resolved = true - continuation.resume(returning: true) - } - } - } - } - } + // MARK: - Sessions & Messages - Task { - try? await Task.sleep(nanoseconds: 5_000_000_000) - if !resolved { - resolved = true - continuation.resume(returning: false) - } - } - } - - if connected { - connectionMode = .direct - await loadSessions() - return true - } else { - await client.disconnect() - wsClient = nil - return false - } + func loadMessages(sessionId: String) async throws -> [FinishedMessage] { + return try await relayClient.requestMessages(sessionId: sessionId) } - func suspend() async { - guard let client = wsClient else { return } - let sessions = await client.getSuspendSessions() - if !sessions.isEmpty { - try? await client.suspend(sessions: sessions) - } + func refreshSessions() async { + await loadSessionsViaRelay() } - // MARK: - Sessions - - func loadSessions() async { + private func loadSessionsViaRelay() async { do { - let response = try await apiClient?.getSessions() ?? SessionsResponse(sessions: [], hasMore: false) + let response = try await relayClient.requestSessions() sessions = response.sessions } catch { - self.error = "Failed to load sessions" + self.error = "Failed to load sessions via relay" + sessions = [] } } - private func loadSessionsViaRelay() async { - // Relay mode can't use the REST API directly — the phone owns the - // WS connection and there's no relay message type for session listing. - // The watch shows an empty list with a hint to open on iPhone. - sessions = [] - } - // MARK: - Active Chat func setActiveChatVM(_ vm: ChatViewModel?) { @@ -184,53 +125,14 @@ final class AppState: ObservableObject { // MARK: - Send (mode-aware) func sendMessage(_ message: ClientMessage) async throws { - switch connectionMode { - case .direct: - try await wsClient?.send(message) - - case .relay: - let dict = try clientMessageToRelayDict(message) - let reply = try await relayClient.send(action: dict["action"] as? String ?? "", params: dict) - if let relayError = reply["error"] as? String { - throw RelayResponseError.serverRejected(relayError) - } - - case .none: + guard connectionMode == .relay else { throw ConnectionError.notConnected } - } - - // MARK: - Event Handling - - private func handleWSEvent(_ event: MitzoWSClient.Event) { - switch event { - case .stateChanged(let state): - connectionState = state - - if case .disconnected = state, connectionMode == .direct { - connectionMode = .none - reconnectWithBackoff() - } - - case .message(let msg): - activeChatVM?.handleMessage(msg) - - case .error(let err): - error = err.localizedDescription - } - } - private func reconnectWithBackoff() { - guard !isReconnecting else { return } - isReconnecting = true - reconnectAttempts += 1 - let baseDelay: UInt64 = 1_000_000_000 // 1s - let delay = min(baseDelay * UInt64(1 << min(reconnectAttempts - 1, 4)), Self.maxReconnectDelay) - - Task { - try? await Task.sleep(nanoseconds: delay) - await connect() - isReconnecting = false + let dict = try clientMessageToRelayDict(message) + let reply = try await relayClient.send(action: dict["action"] as? String ?? "", params: dict) + if let relayError = reply["error"] as? String { + throw RelayResponseError.serverRejected(relayError) } } @@ -317,10 +219,6 @@ final class AppState: ObservableObject { } } - // MARK: - Accessors - - func getWSClient() -> MitzoWSClient? { wsClient } - func getAPIClient() -> MitzoAPIClient? { apiClient } } enum ConnectionError: Error { diff --git a/frontend/ios/MitzoWatch/Services/ChatViewModel.swift b/frontend/ios/MitzoWatch/Services/ChatViewModel.swift index 5b37c9a9..362cd0d6 100644 --- a/frontend/ios/MitzoWatch/Services/ChatViewModel.swift +++ b/frontend/ios/MitzoWatch/Services/ChatViewModel.swift @@ -14,18 +14,15 @@ final class ChatViewModel: ObservableObject { let sessionId: String? private var resolvedSessionId: String? private(set) weak var appState: AppState? - private var wsClient: MitzoWSClient? init(sessionId: String?, appState: AppState? = nil) { self.sessionId = sessionId self.resolvedSessionId = sessionId self.appState = appState - self.wsClient = appState?.getWSClient() } func configure(appState: AppState) { self.appState = appState - self.wsClient = appState.getWSClient() appState.setActiveChatVM(self) } @@ -38,38 +35,34 @@ final class ChatViewModel: ObservableObject { func loadHistory() async { guard let sessionId = resolvedSessionId, - let apiClient = appState?.getAPIClient() else { return } + let appState else { return } do { - let finished: [FinishedMessage] = try await apiClient.getMessages(sessionId: sessionId) + let finished = try await appState.loadMessages(sessionId: sessionId) messages = finished.map { ChatMessage(from: $0) } } catch { // History load failure is non-fatal } - // Watch this session - if let client = wsClient { - try? await client.send(.watch(sessionId: sessionId)) - } + // Watch this session for live updates + try? await appState.sendMessage(.watch(sessionId: sessionId)) } // MARK: - Send Message func send(text: String) async { - guard let client = wsClient else { return } + guard let appState else { return } - // Add user message to display let userMsg = ChatMessage(role: .user, text: text) messages.append(userMsg) - // Send via WS let params = SendParams( sessionId: resolvedSessionId, prompt: text ) do { - try await client.send(.send(params)) + try await appState.sendMessage(.send(params)) isStreaming = true } catch { // Handle send failure @@ -80,7 +73,7 @@ final class ChatViewModel: ObservableObject { func respondToPermission(decision: PermissionDecision) async { guard let perm = permissionRequest, - let client = wsClient else { return } + let appState else { return } let params = PermissionResponseParams( sessionId: resolvedSessionId, @@ -88,7 +81,7 @@ final class ChatViewModel: ObservableObject { decision: decision ) - try? await client.send(.permissionResponse(params)) + try? await appState.sendMessage(.permissionResponse(params)) permissionRequest = nil } @@ -96,9 +89,9 @@ final class ChatViewModel: ObservableObject { func stop() async { guard let sessionId = resolvedSessionId, - let client = wsClient else { return } + let appState else { return } - try? await client.send(.stop(sessionId: sessionId)) + try? await appState.sendMessage(.stop(sessionId: sessionId)) } // MARK: - Process Server Messages diff --git a/frontend/ios/MitzoWatch/Services/VoiceService.swift b/frontend/ios/MitzoWatch/Services/VoiceService.swift index f66bb917..2846364d 100644 --- a/frontend/ios/MitzoWatch/Services/VoiceService.swift +++ b/frontend/ios/MitzoWatch/Services/VoiceService.swift @@ -17,7 +17,7 @@ final class VoiceService: ObservableObject { // Configurable via UserDefaults; defaults to Tailscale hostname var yapperURL: URL { let stored = UserDefaults.standard.string(forKey: "mitzo_yapper_url") - return URL(string: stored ?? "http://mitzo.tail:8700")! + return URL(string: stored ?? "http://100.91.50.57:8700")! } init() { diff --git a/frontend/ios/MitzoWatch/Views/ChatView.swift b/frontend/ios/MitzoWatch/Views/ChatView.swift index 1ae4c9ff..ea33d516 100644 --- a/frontend/ios/MitzoWatch/Views/ChatView.swift +++ b/frontend/ios/MitzoWatch/Views/ChatView.swift @@ -6,8 +6,9 @@ import MitzoShared struct ChatView: View { @EnvironmentObject var appState: AppState @StateObject private var viewModel: ChatViewModel - @StateObject private var voiceService = VoiceService() @State private var scrollProxy: ScrollViewProxy? + @State private var showTextInput = false + @State private var draftText = "" init(sessionId: String?) { _viewModel = StateObject(wrappedValue: ChatViewModel(sessionId: sessionId)) @@ -15,57 +16,92 @@ struct ChatView: View { var body: some View { VStack(spacing: 0) { - // Message stream - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.messages) { message in - MessageBubble(message: message) - .id(message.id) - } + // Message stream — takes all available space + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + Color.clear.frame(height: 1).id("top") + + ForEach(viewModel.messages) { message in + MessageBubble(message: message) + .id(message.id) + } - // Live streaming content - if let stream = viewModel.currentStream { - StreamingBubble(stream: stream) - .id("streaming") - } + // Live streaming content + if let stream = viewModel.currentStream { + StreamingBubble(stream: stream) + .id("streaming") + } - // Tool status pill - if let status = viewModel.toolStatus { - ToolPill(status: status) - .id("tool") + // Tool status pill + if let status = viewModel.toolStatus { + ToolPill(status: status) + .id("tool") + } + + Color.clear.frame(height: 1).id("bottom") } + .padding(.horizontal, 4) } - .padding(.horizontal, 4) - .padding(.bottom, 8) - } - .onChange(of: viewModel.messages.count) { _, _ in - withAnimation { - proxy.scrollTo("streaming", anchor: .bottom) + .onChange(of: viewModel.messages.count) { _, _ in + withAnimation { + proxy.scrollTo("bottom", anchor: .bottom) + } } + .onAppear { scrollProxy = proxy } } - .onAppear { scrollProxy = proxy } - } - // Permission banner - if let perm = viewModel.permissionRequest { - PermissionBanner( - request: perm, - onAllow: { - Task { await viewModel.respondToPermission(decision: .once) } - }, - onDeny: { - Task { await viewModel.respondToPermission(decision: .deny) } + // Permission banner + if let perm = viewModel.permissionRequest { + PermissionBanner( + request: perm, + onAllow: { + Task { await viewModel.respondToPermission(decision: .once) } + }, + onDeny: { + Task { await viewModel.respondToPermission(decision: .deny) } + } + ) + } + + // Bottom bar: scroll nav + compose + HStack(spacing: 8) { + Button { + withAnimation { scrollProxy?.scrollTo("top", anchor: .top) } + } label: { + Image(systemName: "chevron.up") + .font(.system(size: 9, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + + Button { + withAnimation { scrollProxy?.scrollTo("bottom", anchor: .bottom) } + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) } - ) - } + .buttonStyle(.plain) + .foregroundStyle(.secondary) - Divider() + Spacer() - // Voice input bar - VoiceInputBar(voiceService: voiceService) { transcript in - Task { await viewModel.send(text: transcript) } - } + // Compose — opens watchOS native text input (dictation + scribble + keyboard) + Button { + showTextInput = true + } label: { + Image(systemName: "mic.fill") + .font(.system(size: 10)) + .foregroundStyle(.white) + .frame(width: 24, height: 24) + .background(Color.blue) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(height: 32) } .navigationTitle(viewModel.sessionId?.prefix(6).description ?? "New") .navigationBarTitleDisplayMode(.inline) @@ -82,6 +118,12 @@ struct ChatView: View { } } } + .sheet(isPresented: $showTextInput) { + ComposeSheet(draftText: $draftText) { text in + Task { await viewModel.send(text: text) } + showTextInput = false + } + } .task { viewModel.configure(appState: appState) await viewModel.loadHistory() diff --git a/frontend/ios/MitzoWatch/Views/ComposeSheet.swift b/frontend/ios/MitzoWatch/Views/ComposeSheet.swift new file mode 100644 index 00000000..50024230 --- /dev/null +++ b/frontend/ios/MitzoWatch/Views/ComposeSheet.swift @@ -0,0 +1,30 @@ +// Compose sheet — auto-focuses TextField to trigger watchOS native input +// (dictation + scribble + keyboard) + +import SwiftUI + +struct ComposeSheet: View { + @Binding var draftText: String + let onSend: (String) -> Void + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 8) { + TextField("Dictate or type", text: $draftText) + .font(.caption) + .focused($isFocused) + + if !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Send") { + let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) + draftText = "" + onSend(text) + } + .buttonStyle(.borderedProminent) + } + } + .onAppear { + isFocused = true + } + } +} diff --git a/frontend/ios/MitzoWatch/Views/SessionListView.swift b/frontend/ios/MitzoWatch/Views/SessionListView.swift index 4f013dd3..b7df45e1 100644 --- a/frontend/ios/MitzoWatch/Views/SessionListView.swift +++ b/frontend/ios/MitzoWatch/Views/SessionListView.swift @@ -36,7 +36,7 @@ struct SessionListView: View { } } .task { - await appState.loadSessions() + await appState.refreshSessions() } } diff --git a/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift b/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift index eab3daa9..2b394e70 100644 --- a/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift +++ b/frontend/ios/MitzoWatch/Views/VoiceInputBar.swift @@ -1,4 +1,4 @@ -// Voice input bar — push-to-talk with live transcript +// Voice input bar — compact mic toggle for watchOS import SwiftUI @@ -7,65 +7,53 @@ struct VoiceInputBar: View { let onSend: (String) -> Void var body: some View { - VStack(spacing: 4) { + HStack(spacing: 8) { + // Cancel (visible while recording) + if voiceService.isRecording { + Button { + voiceService.cancelRecording() + } label: { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: 24, height: 24) + } + // Partial transcript - if !voiceService.partialTranscript.isEmpty { + if voiceService.isRecording && !voiceService.partialTranscript.isEmpty { Text(voiceService.partialTranscript) - .font(.caption2) + .font(.system(size: 10)) .foregroundStyle(.secondary) - .lineLimit(2) + .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) + } else if !voiceService.isRecording { + Spacer() } - HStack(spacing: 12) { - // Cancel (visible while recording) + // Mic button + Button { if voiceService.isRecording { - Button { - voiceService.cancelRecording() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - - // Mic button — hold to talk - Button { - // Toggle behavior for watch (long press is awkward) - if voiceService.isRecording { - voiceService.stopRecording { transcript in - if !transcript.isEmpty { - onSend(transcript) - } + voiceService.stopRecording { transcript in + if !transcript.isEmpty { + onSend(transcript) } - } else { - voiceService.startRecording() - } - } label: { - ZStack { - Circle() - .fill(voiceService.isRecording ? Color.red : Color.blue) - .frame(width: 44, height: 44) - - Image(systemName: voiceService.isRecording ? "stop.fill" : "mic.fill") - .font(.body) - .foregroundStyle(.white) } + } else { + voiceService.startRecording() } - .buttonStyle(.plain) - - // Recording indicator - if voiceService.isRecording { - Circle() - .fill(.red) - .frame(width: 8, height: 8) - .opacity(voiceService.isRecording ? 1 : 0) - .animation(.easeInOut(duration: 0.5).repeatForever(), value: voiceService.isRecording) - } + } label: { + Image(systemName: voiceService.isRecording ? "stop.fill" : "mic.fill") + .font(.caption) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(voiceService.isRecording ? Color.red : Color.blue) + .clipShape(Circle()) } - .padding(.vertical, 6) + .buttonStyle(.plain) } + .padding(.horizontal, 8) + .padding(.vertical, 4) } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e32d28b..0280d010 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { apiFetch } from './lib/api-fetch'; import { hideSplash } from './lib/splash'; +import { saveTokenToWatch } from './lib/watch-auth'; import { Login } from './pages/Login'; import { SessionList } from './pages/SessionList'; import { ChatView } from './pages/ChatView'; @@ -14,13 +15,20 @@ import { TodoDetailView } from './pages/TodoDetailView'; import { TaskBoard } from './pages/TaskBoard'; import { ErrorBoundary } from './components/ErrorBoundary'; import { MobileShell } from './components/MobileShell'; +import { DesktopShell } from './components/DesktopShell'; import { useIsDesktop } from './hooks/useMediaQuery'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const [auth, setAuth] = useState<'loading' | 'ok' | 'denied'>('loading'); useEffect(() => { apiFetch('/api/auth/check') - .then((r) => setAuth(r.ok ? 'ok' : 'denied')) + .then((r) => { + setAuth(r.ok ? 'ok' : 'denied'); + if (r.ok) { + const token = localStorage.getItem('mitzo_auth_token'); + if (token) saveTokenToWatch(token); + } + }) .catch(() => setAuth('denied')) .finally(() => hideSplash()); }, []); @@ -41,6 +49,12 @@ function ChatRoute() { return isDesktop ? : ; } +function PageRoute({ children }: { children: React.ReactNode }) { + const isDesktop = useIsDesktop(); + if (!isDesktop) return <>{children}; + return ; +} + function dismissKeyboard(e: React.MouseEvent | React.TouchEvent) { const target = e.target as HTMLElement; const tag = target.tagName; @@ -96,7 +110,9 @@ export function App() { path="/inbox" element={ - + + + } /> @@ -104,7 +120,9 @@ export function App() { path="/calendar" element={ - + + + } /> @@ -112,7 +130,9 @@ export function App() { path="/todos" element={ - + + + } /> @@ -120,7 +140,9 @@ export function App() { path="/todos/:id" element={ - + + + } /> @@ -128,7 +150,9 @@ export function App() { path="/tasks" element={ - + + + } /> @@ -137,7 +161,9 @@ export function App() { element={ - + + + } diff --git a/frontend/src/client-store.ts b/frontend/src/client-store.ts index eac4b588..647e4413 100644 --- a/frontend/src/client-store.ts +++ b/frontend/src/client-store.ts @@ -12,6 +12,7 @@ import { apiFetch, getApiBaseUrl, getWsChatUrl } from './lib/api-fetch'; import { registerCapacitorLifecycle } from './lib/capacitor'; import { configureKeyboard } from './lib/keyboard'; import { initPushNotifications } from './lib/push'; +import { eventBus } from './lib/event-bus-singleton'; export const clientStore = createMitzoStore({ transport: { @@ -27,7 +28,10 @@ export const clientStore = createMitzoStore({ // Wire Capacitor app lifecycle → force WS reconnect on resume, send suspend on background registerCapacitorLifecycle( - () => clientStore.getState().forceReconnect(), + () => { + clientStore.getState().forceReconnect(); + eventBus.ensureConnected(); + }, () => clientStore.getState().sendSuspend(), ); diff --git a/frontend/src/components/ActiveSessionsList.tsx b/frontend/src/components/ActiveSessionsList.tsx new file mode 100644 index 00000000..6a31cfbf --- /dev/null +++ b/frontend/src/components/ActiveSessionsList.tsx @@ -0,0 +1,103 @@ +import { useCallback } from 'react'; +import { + useSessionOverview, + type SessionActivity, + type SessionActivityState, +} from '../hooks/useSessionOverview'; + +const STATE_CONFIG: Record = { + init: { icon: '\u25CB', color: '#888', label: 'init' }, + working: { icon: '\u25CF', color: '#b48cff', label: 'working' }, + waiting: { icon: '\u26A0', color: '#ff6d6d', label: 'waiting' }, + done: { icon: '\u2713', color: '#4ade80', label: 'done' }, + idle: { icon: '\u25CB', color: '#555', label: 'idle' }, + paused: { icon: '\u23F8', color: '#888', label: 'paused' }, +}; + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function ActivityCard({ + activity, + isActive, + onTap, +}: { + activity: SessionActivity; + isActive: boolean; + onTap: (sessionId: string) => void; +}) { + const config = STATE_CONFIG[activity.state]; + const elapsed = Date.now() - activity.lastEventAt; + + let metaText = config.label; + if (activity.waitReason === 'permission') metaText = 'permission'; + else if (activity.waitReason === 'review') metaText = 'review needed'; + else if (activity.waitReason === 'blocked') metaText = 'blocked'; + if (activity.progress) { + metaText += ` \u00B7 ${activity.progress.done}/${activity.progress.total}`; + } + metaText += ` \u00B7 ${formatElapsed(elapsed)}`; + + return ( + + ); +} + +export interface ActiveSessionsListProps { + activeSessionId?: string; + onSelectSession: (id: string) => void; +} + +export function ActiveSessionsList({ activeSessionId, onSelectSession }: ActiveSessionsListProps) { + const { activities, attendCount } = useSessionOverview(); + + const visible = activities.filter((a) => a.state !== 'idle' && a.state !== 'init'); + + const handleTap = useCallback( + (sessionId: string) => { + onSelectSession(sessionId); + }, + [onSelectSession], + ); + + if (visible.length === 0) { + return

No active sessions

; + } + + return ( +
+ {attendCount > 0 && ( +
+ {attendCount} need{attendCount === 1 ? 's' : ''} attention +
+ )} + {visible.map((a) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/AttentionFeed.tsx b/frontend/src/components/AttentionFeed.tsx new file mode 100644 index 00000000..aed65ea0 --- /dev/null +++ b/frontend/src/components/AttentionFeed.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAttentionFeed, type AttentionItem } from '../hooks/useAttentionFeed'; +import { selectionChanged } from '../lib/haptics'; + +// ─── Source labels ───────────────────────────────────────────────────────── + +const SOURCE_LABEL: Record = { + telos: 'telos', + atb: 'task', + session: 'session', +}; + +// ─── Card ────────────────────────────────────────────────────────────────── + +function AttentionCard({ + item, + onTap, +}: { + item: AttentionItem; + onTap: (item: AttentionItem) => void; +}) { + return ( + + ); +} + +// ─── Main component ──────────────────────────────────────────────────────── + +export function AttentionFeed() { + const { items, tier1Count, loading } = useAttentionFeed(); + const navigate = useNavigate(); + + const hasUrgent = tier1Count > 0; + const [manualOpen, setManualOpen] = useState(null); + const isOpen = manualOpen ?? true; // always open by default + + const toggleOpen = useCallback(() => { + setManualOpen((prev) => !(prev ?? true)); + }, []); + + const handleTap = useCallback( + (item: AttentionItem) => { + selectionChanged(); + navigate(item.navigateTo); + }, + [navigate], + ); + + // Show section even when empty — gives "all clear" signal + const summaryParts: string[] = []; + if (tier1Count > 0) summaryParts.push(`${tier1Count} needs you`); + const t2Count = items.filter((i) => i.tier === 2).length; + if (t2Count > 0) summaryParts.push(`${t2Count} in focus`); + + return ( +
+ + {isOpen && ( +
+ {!loading && items.length === 0 && ( +
Nothing needs your attention right now.
+ )} + {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx new file mode 100644 index 00000000..21acee65 --- /dev/null +++ b/frontend/src/components/BootContextPill.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import type { BootContextMeta } from '@mitzo/client'; + +interface Props { + context: BootContextMeta; +} + +export function BootContextPill({ context }: Props) { + const [expanded, setExpanded] = useState(false); + + const isContexgin = context.source === 'contexgin'; + const dotClass = isContexgin ? 'boot-context-pill-dot--ok' : 'boot-context-pill-dot--warn'; + const tokenLabel = + context.tokenCount >= 1000 + ? `${(context.tokenCount / 1000).toFixed(1)}k` + : String(context.tokenCount); + const label = `${context.sourceCount} sources \u00b7 ${tokenLabel} tokens`; + + return ( +
+ + {expanded && ( +
+ {context.sources.map((src, idx) => ( +
+ {src} +
+ ))} + {context.trimmedCount > 0 && ( +
+ {context.trimmedCount} section{context.trimmedCount !== 1 ? 's' : ''} trimmed +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index edccfc77..dafba8cc 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -4,6 +4,7 @@ import { ThinkingBlock } from './ThinkingBlock'; import { ToolPill } from './ToolPill'; import { ToolGroup } from './ToolGroup'; import { ContextBlock } from './ContextBlock'; +import { BootContextPill } from './BootContextPill'; import { PermissionBanner } from './PermissionBanner'; import { ProgressWidget } from './ProgressWidget'; import { groupBlocks } from '../lib/groupMessages'; @@ -15,6 +16,7 @@ import type { PermissionRequest, } from '../types/chat'; import type { ProgressBlock } from '@mitzo/protocol'; +import type { BootContextMeta } from '@mitzo/client'; import type { UseVoiceReturn } from '../hooks/useVoice'; export type ChatAreaVoice = Pick< @@ -36,6 +38,8 @@ export interface ChatAreaProps { scrollRef?: React.RefObject; /** Boot context for sessions started from inbox/todo items */ sessionContext?: string | null; + /** Boot context compilation metadata from ContexGin */ + bootContext?: BootContextMeta | null; /** Progress blocks indexed by toolId for rendering ProgressWidget on TodoWrite blocks */ progressByToolId?: Record; /** Voice capabilities for per-block read-aloud */ @@ -50,6 +54,7 @@ export function ChatArea({ onPermissionRespond, scrollRef: externalScrollRef, sessionContext, + bootContext, progressByToolId, voice, }: ChatAreaProps) { @@ -147,9 +152,10 @@ export function ChatArea({ onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > + {bootContext && } {sessionContext && } - {messages.length === 0 && !current && !running && !sessionContext && ( + {messages.length === 0 && !current && !running && !sessionContext && !bootContext && (

Send a message to start

)} diff --git a/frontend/src/components/CollapsibleSection.tsx b/frontend/src/components/CollapsibleSection.tsx new file mode 100644 index 00000000..7b0f392d --- /dev/null +++ b/frontend/src/components/CollapsibleSection.tsx @@ -0,0 +1,61 @@ +import { useState, type ReactNode } from 'react'; + +export interface CollapsibleSectionProps { + title: string; + badge?: number; + storageKey: string; + children: ReactNode; + defaultOpen?: boolean; + actions?: ReactNode; +} + +function readCollapsed(key: string, defaultOpen: boolean): boolean { + try { + const val = localStorage.getItem(key); + if (val === null) return !defaultOpen; + return val === '1'; + } catch { + return !defaultOpen; + } +} + +export function CollapsibleSection({ + title, + badge, + storageKey, + children, + defaultOpen = true, + actions, +}: CollapsibleSectionProps) { + const [collapsed, setCollapsed] = useState(() => readCollapsed(storageKey, defaultOpen)); + + function toggle() { + setCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(storageKey, next ? '1' : '0'); + } catch { + /* ignore */ + } + return next; + }); + } + + return ( +
+ + {!collapsed &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/CommandCenter.tsx b/frontend/src/components/CommandCenter.tsx new file mode 100644 index 00000000..34774005 --- /dev/null +++ b/frontend/src/components/CommandCenter.tsx @@ -0,0 +1,13 @@ +import { InboxSection } from './InboxSection'; +import { TelosSection } from './TelosSection'; +import { TaskBoardSection } from './TaskBoardSection'; + +export function CommandCenter() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/components/DesktopNav.tsx b/frontend/src/components/DesktopNav.tsx new file mode 100644 index 00000000..08d0dec7 --- /dev/null +++ b/frontend/src/components/DesktopNav.tsx @@ -0,0 +1,37 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +interface NavItem { + label: string; + path: string; + match: (pathname: string) => boolean; +} + +export function DesktopNav() { + const location = useLocation(); + const navigate = useNavigate(); + + // Nav only lists full-page routes; sidebar widgets (Inbox, Telos, Tasks) are in CommandCenter. + const items: NavItem[] = [ + { + label: 'Chat', + path: '/', + match: (p) => p === '/' || p === '/chat' || p.startsWith('/chat/'), + }, + { label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') }, + { label: 'Files', path: '/files', match: (p) => p.startsWith('/files') }, + ]; + + return ( + + ); +} diff --git a/frontend/src/components/DesktopShell.tsx b/frontend/src/components/DesktopShell.tsx index 229bdd08..7ac0fcd5 100644 --- a/frontend/src/components/DesktopShell.tsx +++ b/frontend/src/components/DesktopShell.tsx @@ -1,9 +1,10 @@ import { useState, useCallback, type ReactNode } from 'react'; +import { DesktopNav } from './DesktopNav'; export interface DesktopShellProps { - left: ReactNode; + left?: ReactNode; center: ReactNode; - right: ReactNode; + right?: ReactNode; statusBar?: ReactNode; } @@ -55,25 +56,32 @@ export function DesktopShell({ left, center, right, statusBar }: DesktopShellPro - {!leftCollapsed && left} + {!leftCollapsed && ( + <> + + {left} + + )}
{center}
-
- - {!rightCollapsed && right} -
+ + {!rightCollapsed && right} + + )} {statusBar &&
{statusBar}
} diff --git a/frontend/src/components/InboxSection.tsx b/frontend/src/components/InboxSection.tsx new file mode 100644 index 00000000..d18a0ed9 --- /dev/null +++ b/frontend/src/components/InboxSection.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMitzoStore } from '@mitzo/client/hooks'; +import { apiFetch } from '../lib/api-fetch'; +import { buildInboxPrompt, buildInboxContext } from '../lib/inbox-utils'; +import { CollapsibleSection } from './CollapsibleSection'; + +interface InboxItem { + filename: string; + agent: string; + title: string; + tags: string[]; + timestamp: string; + preview: string; +} + +export function InboxSection() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [pendingRemovals, setPendingRemovals] = useState>(new Set()); + const setPendingSession = useMitzoStore((s) => s.setPendingSession); + const storeInbox = useMitzoStore((s) => s.inbox.items); + const loadInbox = useMitzoStore((s) => s.loadInbox); + + useEffect(() => { + loadInbox().then(() => setLoading(false)); + }, [loadInbox]); + + // Sync store inbox to local state, filtering optimistic removals. + // Derive the pruned set inline so filtering uses the up-to-date value + // instead of the stale closure captured before setPendingRemovals runs. + useEffect(() => { + const serverFilenames = new Set((storeInbox as InboxItem[]).map((i) => i.filename)); + setPendingRemovals((prev) => { + const pruned = new Set(); + for (const f of prev) { + if (serverFilenames.has(f)) pruned.add(f); + } + return pruned.size === prev.size ? prev : pruned; + }); + }, [storeInbox]); + + // Derive visible items from store + pending removals (both are reactive) + useEffect(() => { + const filtered = (storeInbox as InboxItem[]).filter( + (item) => !pendingRemovals.has(item.filename), + ); + setItems(filtered); + }, [storeInbox, pendingRemovals]); + + const handleApprove = useCallback( + (filename: string) => { + setPendingRemovals((prev) => new Set(prev).add(filename)); + setItems((prev) => prev.filter((i) => i.filename !== filename)); + apiFetch(`/api/inbox/${encodeURIComponent(filename)}/approve`, { method: 'POST' }) + .then((res) => { + if (!res.ok) loadInbox(); + }) + .catch(() => loadInbox()) + .finally(() => { + setPendingRemovals((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + }); + }, + [loadInbox], + ); + + const handleDiscard = useCallback( + (filename: string) => { + setPendingRemovals((prev) => new Set(prev).add(filename)); + setItems((prev) => prev.filter((i) => i.filename !== filename)); + apiFetch(`/api/inbox/${encodeURIComponent(filename)}`, { method: 'DELETE' }) + .then((res) => { + if (!res.ok) loadInbox(); + }) + .catch(() => loadInbox()) + .finally(() => { + setPendingRemovals((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + }); + }, + [loadInbox], + ); + + const handleStartSession = useCallback( + (item: InboxItem) => { + setPendingSession({ + prompt: buildInboxPrompt(item, item.preview), + context: buildInboxContext(item, item.preview), + }); + navigate('/chat'); + }, + [navigate, setPendingSession], + ); + + return ( + + {loading &&

Loading...

} + {!loading && items.length === 0 &&

No pending proposals

} + {items.map((item) => ( +
+
+
+ {item.agent} {item.title} +
+
{item.preview}
+
+
+ + + +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/LoopControls.tsx b/frontend/src/components/LoopControls.tsx index 0ada829f..d005d80a 100644 --- a/frontend/src/components/LoopControls.tsx +++ b/frontend/src/components/LoopControls.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import type { LoopStatus } from '../types/task'; import type { Task } from '../types/task'; +import { formatTokens } from '../lib/formatTokens'; interface LoopControlsProps { loopStatus: LoopStatus; goals: Task[]; + totalTokenUsage: number; onStart: (goalId: string, specMode?: boolean) => void; onPause: () => void; onResume: () => void; @@ -13,15 +15,10 @@ interface LoopControlsProps { onRejectSpec: () => void; } -const STATE_LABELS: Record = { - idle: 'Idle', - running: 'Running', - paused: 'Paused', -}; - export function LoopControls({ loopStatus, goals, + totalTokenUsage, onStart, onPause, onResume, @@ -29,26 +26,27 @@ export function LoopControls({ onApproveSpec, onRejectSpec, }: LoopControlsProps) { + const [pickerOpen, setPickerOpen] = useState(false); const [selectedGoalId, setSelectedGoalId] = useState(''); const [specMode, setSpecMode] = useState(false); const { state, progress, awaitingApproval } = loopStatus; - return ( -
-
- - {STATE_LABELS[state]} - - {progress && ( - - {progress.done}/{progress.total} - - )} -
+ // ── Idle: compact trigger / expanded picker ── + if (state === 'idle') { + if (!pickerOpen) { + return ( +
+ +
+ ); + } - {state === 'idle' && ( -
+ return ( +
+
setNewSummary(e.target.value)} + placeholder="New item..." + autoFocus + onKeyDown={(e) => { + if (e.key === 'Escape') setCreating(false); + }} + /> + + + )} + + {loading &&

Loading...

} + + {!loading && items.length === 0 &&

No active items

} + + {focus.length > 0 && ( +
+
Focus ({focus.length})
+ {focus.map((item) => ( + + ))} +
+ )} + + {active.length > 0 && ( +
+
Active ({active.length})
+ {active.map((item) => ( + + ))} +
+ )} + + {seen.length > 0 && ( +
+
Seen ({seen.length})
+ {seen.map((item) => ( + + ))} +
+ )} + + ); +} diff --git a/frontend/src/components/TodoCard.tsx b/frontend/src/components/TodoCard.tsx index 348fd3e3..14258735 100644 --- a/frontend/src/components/TodoCard.tsx +++ b/frontend/src/components/TodoCard.tsx @@ -14,11 +14,38 @@ interface TodoCardProps { onStartSession: (item: TodoItem) => void; } -function urgencyBar(urgency: number): string { - if (urgency >= 0.8) return '\u2593\u2593\u2593'; - if (urgency >= 0.5) return '\u2593\u2593\u2591'; - if (urgency >= 0.2) return '\u2593\u2591\u2591'; - return '\u2591\u2591\u2591'; +// ─── Urgency → color border ──────────────────────────────────────────────── + +function urgencyColor(urgency: number): string { + if (urgency >= 0.8) return '#ff6d6d'; + if (urgency >= 0.5) return '#fbbf24'; + if (urgency >= 0.2) return '#b48cff'; + return 'transparent'; +} + +function urgencyWidth(urgency: number): number { + if (urgency >= 0.8) return 4; + if (urgency >= 0.5) return 3; + if (urgency >= 0.2) return 2; + return 0; +} + +// ─── Status visuals ──────────────────────────────────────────────────────── + +function getStatusIcon(item: TodoItem): string { + if (item.starred) return '\u2605'; // ★ + if (item.status === 'active') return '\u25CF'; // ● + if (item.status === 'acknowledged') return '\u25D0'; // ◐ + if (item.status === 'completed') return '\u2713'; // ✓ + return '\u25CB'; // ○ (snoozed) +} + +function getStatusColor(item: TodoItem): string { + if (item.starred) return '#fbbf24'; + if (item.status === 'active') return '#b48cff'; + if (item.status === 'acknowledged') return '#60a5fa'; + if (item.status === 'completed') return '#4ade80'; + return '#888'; } export function TodoCard({ @@ -102,9 +129,12 @@ export function TodoCard({ const source = item.sources[0]; const ageLabel = item.ageDays === 0 ? 'new' : `${item.ageDays}d`; - const statusIcon = item.status === 'active' ? '\u25CF' : '\u25D0'; const children = item.children ?? []; const hasChildren = children.length > 0; + const icon = getStatusIcon(item); + const color = getStatusColor(item); + const borderClr = urgencyColor(item.urgency); + const borderW = urgencyWidth(item.urgency); return (
0 ? 'todo-card-tree-node--child' : ''}`}> @@ -119,8 +149,18 @@ export function TodoCard({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} + style={{ + borderLeftColor: borderClr, + borderLeftWidth: borderW > 0 ? `${borderW}px` : undefined, + borderLeftStyle: borderW > 0 ? 'solid' : undefined, + }} > -
+ {/* Line 1: icon + summary + star */} +
+ + {icon} + + {item.summary} {hasChildren && (
-
{item.summary}
- {source && ( -
- {source.author} -
- )} -
+ + {/* Line 2: source + meta */} +
+ {source ? ( + {sourceIcon(source.type)} + ) : ( + + + )} + {source?.author && ( + <> + {source.author} + {' \u00B7 '} + + )} + {ageLabel} + {' \u00B7 '} + {item.profile} + {hasChildren && ( + <> + {' \u00B7 '} + + {item.completedChildCount ?? 0}/{item.childCount ?? children.length} + + + )} +
+ + {/* Line 3: actions */} +
)} + {block.subagent && }
); } diff --git a/frontend/src/components/__tests__/DesktopShell.test.tsx b/frontend/src/components/__tests__/DesktopShell.test.tsx index 42625613..3670b255 100644 --- a/frontend/src/components/__tests__/DesktopShell.test.tsx +++ b/frontend/src/components/__tests__/DesktopShell.test.tsx @@ -1,6 +1,11 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; + +vi.mock('../DesktopNav', () => ({ + DesktopNav: () => , +})); + import { DesktopShell } from '../DesktopShell'; beforeEach(() => { @@ -31,7 +36,7 @@ describe('DesktopShell', () => { right={
Right
} />, ); - const toggleBtn = screen.getByTitle('Hide sessions'); + const toggleBtn = screen.getByTitle('Hide sidebar'); fireEvent.click(toggleBtn); expect(screen.queryByText('Left Panel')).toBeNull(); expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('1'); @@ -61,7 +66,7 @@ describe('DesktopShell', () => { />, ); expect(screen.queryByText('Left Panel')).toBeNull(); - expect(screen.getByTitle('Show sessions')).toBeTruthy(); + expect(screen.getByTitle('Show sidebar')).toBeTruthy(); }); it('expands collapsed sidebar on toggle', () => { @@ -73,7 +78,7 @@ describe('DesktopShell', () => { right={
Right
} />, ); - fireEvent.click(screen.getByTitle('Show sessions')); + fireEvent.click(screen.getByTitle('Show sidebar')); expect(screen.getByText('Left Panel')).toBeTruthy(); expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('0'); }); diff --git a/frontend/src/components/__tests__/MessageBubble.test.ts b/frontend/src/components/__tests__/MessageBubble.test.ts index 914544fe..24b4058b 100644 --- a/frontend/src/components/__tests__/MessageBubble.test.ts +++ b/frontend/src/components/__tests__/MessageBubble.test.ts @@ -4,18 +4,24 @@ import { describe, it, expect, vi } from 'vitest'; // eslint-disable-next-line @typescript-eslint/no-explicit-any let capturedComponents: Record | undefined; let capturedContent: string | undefined; + +let capturedUrlTransform: ((url: string) => string) | undefined; vi.mock('react-markdown', () => ({ default: ({ children, components, + urlTransform, }: { children: string; components?: Record; + urlTransform?: (url: string) => string; }) => { capturedComponents = components; capturedContent = children; + capturedUrlTransform = urlTransform; return children; }, + defaultUrlTransform: (url: string) => `sanitized:${url}`, })); vi.mock('remark-gfm', () => ({ default: () => {} })); @@ -220,6 +226,94 @@ describe('TextBubble code block CopyButton', () => { }); }); +describe('TextBubble markdown preview card promotion', () => { + it('provides a custom p component to ReactMarkdown', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + expect(capturedComponents).toBeDefined(); + expect(capturedComponents!.p).toBeDefined(); + }); + + it('promotes a standalone .md file-path link to MarkdownPreviewCard', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + // Simulate what ReactMarkdown v10 passes: an unrendered component with href prop + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const link = createElement('a', { href: fileHref }, '/tmp/notes.md'); + + const result = p({ children: link }); + // Should return a MarkdownPreviewCard, not a

+ expect(result.type).not.toBe('p'); + expect(result.props.filePath).toBe('/tmp/notes.md'); + }); + + it('does not promote non-.md file-path links', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/data.json')}`; + const link = createElement('a', { href: fileHref }, '/tmp/data.json'); + + const result = p({ children: link }); + expect(result.type).toBe('p'); + }); + + it('does not promote .md links when paragraph has other content', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const link = createElement('a', { href: fileHref }, '/tmp/notes.md'); + + const result = p({ children: ['See ', link, ' for details'] }); + expect(result.type).toBe('p'); + }); + + it('promotes a standalone .mdx file-path link to MarkdownPreviewCard', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/design.mdx')}`; + const link = createElement('a', { href: fileHref }, '/tmp/design.mdx'); + + const result = p({ children: link }); + expect(result.type).not.toBe('p'); + expect(result.props.filePath).toBe('/tmp/design.mdx'); + }); + + it('does not promote regular URL links in a solo paragraph', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const p = capturedComponents!.p; + const link = createElement('a', { href: 'https://example.com' }, 'Example'); + + const result = p({ children: link }); + expect(result.type).toBe('p'); + }); + + it('a handler sets data-file-path on file-path links', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const anchor = capturedComponents!.a; + const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; + const rendered = anchor({ href: fileHref, children: '/tmp/notes.md' }); + expect(rendered.props['data-file-path']).toBe('/tmp/notes.md'); + }); +}); + +describe('TextBubble urlTransform', () => { + it('preserves file-path:// URLs and delegates others to defaultUrlTransform', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + expect(capturedUrlTransform).toBeDefined(); + + // file-path:// URLs are preserved as-is (not passed through defaultUrlTransform) + const fileUrl = 'file-path://%2Ftmp%2Fnotes.md'; + expect(capturedUrlTransform!(fileUrl)).toBe(fileUrl); + + // Non-file-path URLs are delegated to defaultUrlTransform (mock prefixes with "sanitized:") + const httpUrl = 'https://example.com'; + expect(capturedUrlTransform!(httpUrl)).toBe(`sanitized:${httpUrl}`); + + // Dangerous schemes should also go through defaultUrlTransform, not be preserved + const jsUrl = 'javascript:alert(1)'; + expect(capturedUrlTransform!(jsUrl)).toBe(`sanitized:${jsUrl}`); + }); +}); + describe('MessageBubble legacy adapter forwards timestamps', () => { it('forwards timestamp to UserBubble for user messages', () => { const ts = Date.now(); diff --git a/frontend/src/components/__tests__/ServiceStatus.test.tsx b/frontend/src/components/__tests__/ServiceStatus.test.tsx new file mode 100644 index 00000000..bd3b6696 --- /dev/null +++ b/frontend/src/components/__tests__/ServiceStatus.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ServiceStatus } from '../ServiceStatus'; + +// Mock useServiceHealth +let mockReturn = { + services: [] as any[], + yapper: null as any, + contexgin: null as any, + checkedAt: 0, +}; + +vi.mock('../../hooks/useServiceHealth', () => ({ + useServiceHealth: () => mockReturn, +})); + +describe('ServiceStatus', () => { + beforeEach(() => { + mockReturn = { services: [], yapper: null, contexgin: null, checkedAt: 0 }; + }); + + afterEach(() => { + cleanup(); + }); + + it('renders nothing before first health check', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders service dots after health check', () => { + mockReturn = { + services: [], + yapper: { name: 'yapper', ok: true }, + contexgin: { name: 'contexgin', ok: false }, + checkedAt: Date.now(), + }; + + const { container } = render(); + const dots = container.querySelectorAll('.service-dot'); + expect(dots).toHaveLength(2); + expect(dots[0].textContent).toBe('yapper'); + expect(dots[1].textContent).toBe('contexgin'); + }); + + it('shows green indicator for ok services', () => { + mockReturn = { + services: [], + yapper: { name: 'yapper', ok: true }, + contexgin: null, + checkedAt: Date.now(), + }; + + const { container } = render(); + const dot = container.querySelector('.service-dot') as HTMLElement; + expect(dot.style.color).toBe('rgb(74, 222, 128)'); + }); + + it('shows red indicator for down services', () => { + mockReturn = { + services: [], + yapper: null, + contexgin: { name: 'contexgin', ok: false }, + checkedAt: Date.now(), + }; + + const { container } = render(); + const dot = container.querySelector('.service-dot') as HTMLElement; + expect(dot.style.color).toBe('rgb(255, 109, 109)'); + }); + + it('renders nothing when both services are null', () => { + mockReturn = { services: [], yapper: null, contexgin: null, checkedAt: Date.now() }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/src/components/__tests__/SessionOverview.test.tsx b/frontend/src/components/__tests__/SessionOverview.test.tsx new file mode 100644 index 00000000..38fb9027 --- /dev/null +++ b/frontend/src/components/__tests__/SessionOverview.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { SessionActivity } from '../../hooks/useSessionOverview'; + +// Mock navigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await import('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +// Mock haptics +vi.mock('../../lib/haptics', () => ({ + selectionChanged: vi.fn(), +})); + +// Mock the hook +const mockActivities: SessionActivity[] = []; +let mockAttendCount = 0; + +vi.mock('../../hooks/useSessionOverview', () => ({ + useSessionOverview: () => ({ + activities: mockActivities, + attendCount: mockAttendCount, + connected: true, + }), +})); + +import { SessionOverview } from '../SessionOverview'; + +afterEach(() => { + cleanup(); + mockNavigate.mockClear(); + mockActivities.length = 0; + mockAttendCount = 0; +}); + +function renderOverview() { + return render( + + + , + ); +} + +function makeActivity(overrides: Partial = {}): SessionActivity { + return { + sessionId: 'session-1', + clientId: 'client-1', + title: 'Test Session', + state: 'working', + flags: [], + lastEventAt: Date.now(), + ...overrides, + }; +} + +describe('SessionOverview', () => { + it('renders nothing when no interesting sessions', () => { + const { container } = renderOverview(); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when only idle/init sessions', () => { + mockActivities.push( + makeActivity({ state: 'idle' }), + makeActivity({ sessionId: 's2', state: 'init' }), + ); + const { container } = renderOverview(); + expect(container.innerHTML).toBe(''); + }); + + it('renders header with summary when working sessions exist', () => { + mockActivities.push(makeActivity({ state: 'working' })); + renderOverview(); + expect(screen.getByText('Active Sessions')).toBeTruthy(); + expect(screen.getByText('1 working')).toBeTruthy(); + }); + + it('shows badge when waiting sessions exist', () => { + mockActivities.push(makeActivity({ state: 'waiting', waitReason: 'permission' })); + mockAttendCount = 1; + renderOverview(); + expect(screen.getByText('1')).toBeTruthy(); + }); + + it('auto-expands when attend count > 0', () => { + mockActivities.push(makeActivity({ state: 'waiting' })); + mockAttendCount = 1; + renderOverview(); + // Cards should be visible + expect(screen.getByText('Test Session')).toBeTruthy(); + }); + + it('navigates on card tap', () => { + mockActivities.push(makeActivity({ state: 'working' })); + mockAttendCount = 1; // auto-expand + renderOverview(); + + const card = screen.getByText('Test Session').closest('button'); + fireEvent.click(card!); + expect(mockNavigate).toHaveBeenCalledWith('/chat/session-1'); + }); + + it('shows repo prefix in card title', () => { + mockActivities.push(makeActivity({ state: 'working', repo: 'mitzo' })); + mockAttendCount = 1; + renderOverview(); + expect(screen.getByText('mitzo:')).toBeTruthy(); + }); + + it('shows progress in card meta', () => { + mockActivities.push(makeActivity({ state: 'working', progress: { done: 3, total: 7 } })); + mockAttendCount = 1; + renderOverview(); + // The meta text should contain progress + const meta = document.querySelector('.overview-card-meta'); + expect(meta?.textContent).toContain('3/7'); + }); + + it('toggles expansion on header click', () => { + mockActivities.push(makeActivity({ state: 'working' })); + // No attend count, so starts collapsed + mockAttendCount = 0; + renderOverview(); + + // Should be collapsed initially (no card visible) + expect(screen.queryByText('Test Session')).toBeNull(); + + // Click header to expand + fireEvent.click(screen.getByText('Active Sessions')); + expect(screen.getByText('Test Session')).toBeTruthy(); + + // Click again to collapse + fireEvent.click(screen.getByText('Active Sessions')); + expect(screen.queryByText('Test Session')).toBeNull(); + }); +}); diff --git a/frontend/src/components/__tests__/SubagentCard.test.tsx b/frontend/src/components/__tests__/SubagentCard.test.tsx new file mode 100644 index 00000000..90a99b09 --- /dev/null +++ b/frontend/src/components/__tests__/SubagentCard.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { SubagentCard } from '../SubagentCard'; +import type { FinishedBlock } from '../../types/chat'; + +afterEach(() => cleanup()); + +describe('SubagentCard', () => { + it('renders collapsed by default with summary', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: [ + { + blockId: 'b1', + blockType: 'text' as const, + content: 'Subagent output', + }, + ], + summary: 'Search complete', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }; + + render(); + + expect(screen.getByText('Search complete')).toBeTruthy(); + expect(screen.getByText(/100.*50/)).toBeTruthy(); // Token display + }); + + it('renders "Working..." when subagent is still running', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'thinking' as const, + content: 'Analyzing...', + done: false, + }, + ], + ]), + blockOrder: ['b1'], + running: true as const, + }; + + render(); + + expect(screen.getByText('Working...')).toBeTruthy(); + }); + + it('expands to show nested blocks when clicked', () => { + const blocks: FinishedBlock[] = [ + { + blockId: 'b1', + blockType: 'thinking', + content: 'Let me search for that', + }, + { + blockId: 'b2', + blockType: 'text', + content: 'Search results here', + }, + ]; + + const subagent = { + messageId: 'msg-sub-1', + blocks, + summary: 'Done', + }; + + const { container } = render(); + + // Initially collapsed - detail not visible + expect(container.querySelector('.subagent-detail')).not.toBeTruthy(); + + // Click to expand + const header = container.querySelector('.subagent-header'); + if (header) { + fireEvent.click(header); + } + + // Now detail section is visible + expect(container.querySelector('.subagent-detail')).toBeTruthy(); + expect(screen.getByText('Search results here')).toBeTruthy(); + }); + + it('shows pulsing indicator when running', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: new Map(), + blockOrder: [], + running: true as const, + }; + + const { container } = render(); + + const dot = container.querySelector('.subagent-dot--running'); + expect(dot).toBeTruthy(); + }); + + it('shows checkmark when complete', () => { + const subagent = { + messageId: 'msg-sub-1', + blocks: [], + summary: 'Complete', + }; + + const { container } = render(); + + const dot = container.querySelector('.subagent-dot--done'); + expect(dot).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/__tests__/TabBar.test.tsx b/frontend/src/components/__tests__/TabBar.test.tsx index f1a7e5ac..b1b492b2 100644 --- a/frontend/src/components/__tests__/TabBar.test.tsx +++ b/frontend/src/components/__tests__/TabBar.test.tsx @@ -60,7 +60,7 @@ describe('TabBar', () => { expect(screen.getByText('Chat')).toBeTruthy(); expect(screen.getByText('Calendar')).toBeTruthy(); expect(screen.getByText('Inbox')).toBeTruthy(); - expect(screen.getByText('Todos')).toBeTruthy(); + expect(screen.getByText('Telos')).toBeTruthy(); expect(screen.getByText('More')).toBeTruthy(); }); @@ -82,9 +82,9 @@ describe('TabBar', () => { expect(badge.className).toContain('tab-bar-badge'); }); - it('does not show badge on Todos tab when count is 0', () => { + it('does not show badge on Telos tab when count is 0', () => { renderAt('/'); - const todosTab = screen.getByText('Todos').closest('button'); + const todosTab = screen.getByText('Telos').closest('button'); expect(todosTab?.querySelector('.tab-bar-badge')).toBeNull(); }); diff --git a/frontend/src/hooks/__tests__/useAttentionFeed.test.ts b/frontend/src/hooks/__tests__/useAttentionFeed.test.ts new file mode 100644 index 00000000..6d8531ad --- /dev/null +++ b/frontend/src/hooks/__tests__/useAttentionFeed.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +// ─── Mocks ───────────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockEventBusOn: any = vi.fn(() => vi.fn()); + +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: (...args: unknown[]) => mockEventBusOn(...args), + }, +})); + +vi.mock('../../lib/api-fetch', () => ({ + apiFetch: vi.fn(), +})); + +const mockTasks = { tree: [], loopStatus: { state: 'idle' } }; +const mockLoadTasks = vi.fn(); + +vi.mock('@mitzo/client/hooks', () => ({ + useMitzoStore: (selector: (s: Record) => unknown) => + selector({ tasks: mockTasks, loadTasks: mockLoadTasks }), +})); + +import { useAttentionFeed } from '../useAttentionFeed'; +import { apiFetch } from '../../lib/api-fetch'; +import type { TodoItem } from '../../types/todo'; + +const mockApiFetch = vi.mocked(apiFetch); + +function makeTodo(overrides: Partial = {}): TodoItem { + return { + id: 'todo-1', + summary: 'Test todo', + profile: 'work', + urgency: 0.5, + starred: false, + status: 'active', + ageDays: 3, + parentId: null, + children: [], + childCount: 0, + completedChildCount: 0, + sources: [], + contextHints: { + repos: [], + paths: [], + issues: [], + docIds: [], + people: [], + jiraKeys: [], + keywords: [], + taskHint: '', + }, + ...overrides, + }; +} + +beforeEach(() => { + mockEventBusOn.mockClear(); + mockLoadTasks.mockClear(); + mockApiFetch.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useAttentionFeed', () => { + it('loads todos from API on mount', async () => { + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [] }), + } as Response); + + renderHook(() => useAttentionFeed()); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/todos'); + }); + + it('loads tasks from store on mount', () => { + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [] }), + } as Response); + + renderHook(() => useAttentionFeed()); + + expect(mockLoadTasks).toHaveBeenCalled(); + }); + + it('returns starred high-urgency todos as tier 1', async () => { + const items = [ + makeTodo({ id: 't1', summary: 'Urgent starred', starred: true, urgency: 0.9 }), + makeTodo({ id: 't2', summary: 'Normal item', starred: false, urgency: 0.3 }), + ]; + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items }), + } as Response); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0].title).toBe('Urgent starred'); + expect(result.current.items[0].tier).toBe(1); + expect(result.current.tier1Count).toBe(1); + }); + + it('returns starred low-urgency todos as tier 2', async () => { + const items = [ + makeTodo({ id: 't1', summary: 'Starred low urgency', starred: true, urgency: 0.3 }), + ]; + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items }), + } as Response); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0].tier).toBe(2); + }); + + it('returns unstarred very-high-urgency todos as tier 2', async () => { + const items = [ + makeTodo({ id: 't1', summary: 'Critical unstarred', starred: false, urgency: 0.9 }), + ]; + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items }), + } as Response); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0].tier).toBe(2); + }); + + it('sorts tier 1 items before tier 2', async () => { + const items = [ + makeTodo({ id: 't1', summary: 'Starred low', starred: true, urgency: 0.3 }), + makeTodo({ id: 't2', summary: 'Starred high', starred: true, urgency: 0.8 }), + ]; + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items }), + } as Response); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items[0].title).toBe('Starred high'); + expect(result.current.items[0].tier).toBe(1); + expect(result.current.items[1].title).toBe('Starred low'); + expect(result.current.items[1].tier).toBe(2); + }); + + it('caps output at 5 items', async () => { + const items = Array.from({ length: 10 }, (_, i) => + makeTodo({ id: `t${i}`, summary: `Item ${i}`, starred: true, urgency: 0.9 }), + ); + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items }), + } as Response); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items).toHaveLength(5); + }); + + it('subscribes to SSE events for live updates', () => { + mockApiFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [] }), + } as Response); + + renderHook(() => useAttentionFeed()); + + const eventNames = mockEventBusOn.mock.calls.map((c: unknown[]) => c[0]); + expect(eventNames).toContain('todo_update'); + expect(eventNames).toContain('task_state'); + expect(eventNames).toContain('session_activity'); + }); + + it('handles API failure gracefully', async () => { + mockApiFetch.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useAttentionFeed()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.items).toEqual([]); + }); +}); diff --git a/frontend/src/hooks/__tests__/useDocumentReader.test.ts b/frontend/src/hooks/__tests__/useDocumentReader.test.ts index 198944a9..a67d504f 100644 --- a/frontend/src/hooks/__tests__/useDocumentReader.test.ts +++ b/frontend/src/hooks/__tests__/useDocumentReader.test.ts @@ -14,75 +14,64 @@ vi.mock('../../lib/tts', () => ({ // Mock constants vi.mock('../../lib/constants', () => ({ YAPPER_URL: 'http://test-yapper', - YAPPER_HEALTH_POLL_MS: 30000, DEFAULT_TTS_VOICE: 'af_heart', DOCUMENT_READ_MAX_CHARS: 50_000, })); +// Mock useServiceHealth — control yapper status per test +let mockYapper: { ok: boolean; detail?: Record } | null = null; +vi.mock('../useServiceHealth', () => ({ + useServiceHealth: () => ({ + services: mockYapper ? [mockYapper] : [], + yapper: mockYapper, + contexgin: null, + checkedAt: mockYapper ? Date.now() : 0, + }), +})); + const mockFetch = vi.fn(); describe('useDocumentReader', () => { beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); vi.stubGlobal('fetch', mockFetch); + mockYapper = null; }); afterEach(() => { - vi.useRealTimers(); vi.restoreAllMocks(); }); - it('starts unavailable before health check', () => { - // Make fetch hang forever - mockFetch.mockReturnValue(new Promise(() => {})); + it('starts unavailable when no health data', () => { + mockYapper = null; const { result } = renderHook(() => useDocumentReader()); expect(result.current.available).toBe(false); expect(result.current.state).toBe('idle'); }); - it('becomes available after successful health check', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ status: 'ready', models: { stt: true, tts: true } }), - }); - + it('becomes available when yapper health reports ok with tts', () => { + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useDocumentReader()); - await act(async () => {}); - expect(result.current.available).toBe(true); }); - it('stays unavailable when TTS model is not ready', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ status: 'ready', models: { stt: true, tts: false } }), - }); - + it('stays unavailable when TTS model is not ready', () => { + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useDocumentReader()); - await act(async () => {}); - expect(result.current.available).toBe(false); }); - it('stays unavailable when health check fails', async () => { - mockFetch.mockRejectedValue(new Error('network')); - + it('stays unavailable when yapper is down', () => { + mockYapper = { ok: false }; const { result } = renderHook(() => useDocumentReader()); - await act(async () => {}); - expect(result.current.available).toBe(false); }); it('calls synthesizeDocument and playAudio on read()', async () => { const { synthesizeDocument, playAudio } = await import('../../lib/tts'); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ status: 'ready' }), - }); + mockYapper = { ok: true, detail: { tts: true } }; const { result } = renderHook(() => useDocumentReader()); - await act(async () => {}); await act(async () => { result.current.read('# Hello\n\nWorld'); @@ -99,22 +88,15 @@ describe('useDocumentReader', () => { }); it('stops playback on stop()', async () => { - // Make play() hang so state stays 'playing' until stop() is called mockPlayHandle.play.mockReturnValue(new Promise(() => {})); - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ status: 'ready' }), - }); + mockYapper = { ok: true, detail: { tts: true } }; const { result } = renderHook(() => useDocumentReader()); - await act(async () => {}); act(() => { result.current.read('Hello'); }); - // Flush async pipeline up to playAudio() await act(async () => {}); expect(result.current.state).toBe('playing'); @@ -126,7 +108,6 @@ describe('useDocumentReader', () => { expect(mockPlayHandle.stop).toHaveBeenCalled(); expect(result.current.state).toBe('idle'); - // Restore default mock for other tests mockPlayHandle.play.mockResolvedValue(undefined); }); }); diff --git a/frontend/src/hooks/__tests__/useServiceHealth.test.ts b/frontend/src/hooks/__tests__/useServiceHealth.test.ts new file mode 100644 index 00000000..22bb1b4c --- /dev/null +++ b/frontend/src/hooks/__tests__/useServiceHealth.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useServiceHealth } from '../useServiceHealth'; + +// Capture the listener registered via eventBus.on('health', ...) +let healthListener: ((data: unknown) => void) | null = null; +const mockUnsub = vi.fn(); + +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn((event: string, listener: (data: unknown) => void) => { + if (event === 'health') healthListener = listener; + return mockUnsub; + }), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + +describe('useServiceHealth', () => { + beforeEach(() => { + healthListener = null; + mockUnsub.mockClear(); + }); + + it('starts with empty services and checkedAt=0', () => { + const { result } = renderHook(() => useServiceHealth()); + expect(result.current.services).toEqual([]); + expect(result.current.yapper).toBeNull(); + expect(result.current.contexgin).toBeNull(); + expect(result.current.checkedAt).toBe(0); + }); + + it('subscribes to health event on mount', () => { + renderHook(() => useServiceHealth()); + expect(healthListener).not.toBeNull(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useServiceHealth()); + unmount(); + expect(mockUnsub).toHaveBeenCalled(); + }); + + it('updates state when health event fires', () => { + const { result } = renderHook(() => useServiceHealth()); + + act(() => { + healthListener?.({ + services: [ + { name: 'yapper', ok: true, detail: { stt: true, tts: true } }, + { name: 'contexgin', ok: false }, + ], + checkedAt: 1234567890, + }); + }); + + expect(result.current.services).toHaveLength(2); + expect(result.current.yapper).toEqual({ + name: 'yapper', + ok: true, + detail: { stt: true, tts: true }, + }); + expect(result.current.contexgin).toEqual({ name: 'contexgin', ok: false }); + expect(result.current.checkedAt).toBe(1234567890); + }); + + it('returns null for unknown service names', () => { + const { result } = renderHook(() => useServiceHealth()); + + act(() => { + healthListener?.({ + services: [{ name: 'unknown-service', ok: true }], + checkedAt: 1000, + }); + }); + + expect(result.current.yapper).toBeNull(); + expect(result.current.contexgin).toBeNull(); + }); + + it('updates when payload changes', () => { + const { result } = renderHook(() => useServiceHealth()); + + act(() => { + healthListener?.({ + services: [{ name: 'yapper', ok: true }], + checkedAt: 1000, + }); + }); + expect(result.current.yapper?.ok).toBe(true); + + act(() => { + healthListener?.({ + services: [{ name: 'yapper', ok: false }], + checkedAt: 2000, + }); + }); + expect(result.current.yapper?.ok).toBe(false); + expect(result.current.checkedAt).toBe(2000); + }); +}); diff --git a/frontend/src/hooks/__tests__/useSessionList.test.ts b/frontend/src/hooks/__tests__/useSessionList.test.ts index a955233e..0265fb07 100644 --- a/frontend/src/hooks/__tests__/useSessionList.test.ts +++ b/frontend/src/hooks/__tests__/useSessionList.test.ts @@ -10,6 +10,14 @@ vi.mock('../../lib/api-fetch', () => ({ apiFetch: vi.fn(), })); +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + import { apiFetch } from '../../lib/api-fetch'; import { useSessionList } from '../useSessionList'; diff --git a/frontend/src/hooks/__tests__/useSessionOverview.test.ts b/frontend/src/hooks/__tests__/useSessionOverview.test.ts new file mode 100644 index 00000000..80559d4f --- /dev/null +++ b/frontend/src/hooks/__tests__/useSessionOverview.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock the event bus singleton +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockOn: any = vi.fn(() => vi.fn()); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockOnConnectionChange: any = vi.fn(() => vi.fn()); +const mockConnected = false; + +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: (...args: unknown[]) => mockOn(...args), + onConnectionChange: (...args: unknown[]) => mockOnConnectionChange(...args), + get connected() { + return mockConnected; + }, + }, +})); + +import { useSessionOverview, type SessionActivity } from '../useSessionOverview'; + +beforeEach(() => { + mockOn.mockClear(); + mockOnConnectionChange.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeActivity(overrides: Partial = {}): SessionActivity { + return { + sessionId: 'session-1', + clientId: 'client-1', + title: 'Test Session', + state: 'working', + flags: [], + lastEventAt: Date.now(), + ...overrides, + }; +} + +describe('useSessionOverview', () => { + it('starts with empty activities', () => { + const { result } = renderHook(() => useSessionOverview()); + expect(result.current.activities).toEqual([]); + expect(result.current.attendCount).toBe(0); + }); + + it('subscribes to session_activity events on mount', () => { + renderHook(() => useSessionOverview()); + expect(mockOn).toHaveBeenCalledWith('session_activity', expect.any(Function)); + }); + + it('subscribes to connection changes', () => { + renderHook(() => useSessionOverview()); + expect(mockOnConnectionChange).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('unsubscribes on unmount', () => { + const unsubActivity = vi.fn(); + const unsubConnection = vi.fn(); + mockOn.mockReturnValue(unsubActivity); + mockOnConnectionChange.mockReturnValue(unsubConnection); + + const { unmount } = renderHook(() => useSessionOverview()); + unmount(); + + expect(unsubActivity).toHaveBeenCalled(); + expect(unsubConnection).toHaveBeenCalled(); + }); + + it('updates activities when session_activity event fires', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + const activities = [ + makeActivity({ sessionId: 's1', state: 'working' }), + makeActivity({ sessionId: 's2', state: 'waiting', waitReason: 'permission' }), + ]; + + act(() => { + activityHandler!(activities); + }); + + expect(result.current.activities).toHaveLength(2); + // Waiting (tier 1) should sort before working (tier 3) + expect(result.current.activities[0].state).toBe('waiting'); + expect(result.current.activities[1].state).toBe('working'); + }); + + it('computes attendCount from waiting sessions', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + act(() => { + activityHandler!([ + makeActivity({ sessionId: 's1', state: 'waiting' }), + makeActivity({ sessionId: 's2', state: 'waiting' }), + makeActivity({ sessionId: 's3', state: 'working' }), + ]); + }); + + expect(result.current.attendCount).toBe(2); + }); + + it('sorts by tier then by recency within tier', () => { + let activityHandler: ((data: unknown) => void) | null = null; + mockOn.mockImplementation((_event: string, handler: (data: unknown) => void) => { + activityHandler = handler; + return vi.fn(); + }); + + const { result } = renderHook(() => useSessionOverview()); + + const now = Date.now(); + act(() => { + activityHandler!([ + makeActivity({ sessionId: 's1', state: 'done', lastEventAt: now - 1000 }), + makeActivity({ sessionId: 's2', state: 'working', lastEventAt: now }), + makeActivity({ sessionId: 's3', state: 'done', lastEventAt: now }), + makeActivity({ sessionId: 's4', state: 'waiting', lastEventAt: now }), + ]); + }); + + const ids = result.current.activities.map((a) => a.sessionId); + // Tier 1 (waiting) → Tier 2 (done, newest first) → Tier 3 (working) + expect(ids).toEqual(['s4', 's3', 's1', 's2']); + }); +}); diff --git a/frontend/src/hooks/__tests__/useTabBadges.test.ts b/frontend/src/hooks/__tests__/useTabBadges.test.ts index a84ff7ca..636ed068 100644 --- a/frontend/src/hooks/__tests__/useTabBadges.test.ts +++ b/frontend/src/hooks/__tests__/useTabBadges.test.ts @@ -6,6 +6,14 @@ import { MitzoStoreProvider } from '@mitzo/client/hooks'; import { createMitzoStore } from '@mitzo/client'; import type { WebSocketLike } from '@mitzo/client'; import { WS_READY_STATE } from '@mitzo/client'; +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + import { useTabBadges } from '../useTabBadges'; class MockWebSocket implements WebSocketLike { diff --git a/frontend/src/hooks/__tests__/useTaskBoard.test.ts b/frontend/src/hooks/__tests__/useTaskBoard.test.ts index 16840cee..886d1507 100644 --- a/frontend/src/hooks/__tests__/useTaskBoard.test.ts +++ b/frontend/src/hooks/__tests__/useTaskBoard.test.ts @@ -6,6 +6,14 @@ import { MitzoStoreProvider } from '@mitzo/client/hooks'; import { createMitzoStore } from '@mitzo/client'; import type { WebSocketLike } from '@mitzo/client'; import { WS_READY_STATE } from '@mitzo/client'; +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + import { useTaskBoard } from '../useTaskBoard'; class MockWebSocket implements WebSocketLike { diff --git a/frontend/src/hooks/__tests__/useTodoData.test.ts b/frontend/src/hooks/__tests__/useTodoData.test.ts index 1a63e3b3..75ec227b 100644 --- a/frontend/src/hooks/__tests__/useTodoData.test.ts +++ b/frontend/src/hooks/__tests__/useTodoData.test.ts @@ -6,6 +6,14 @@ vi.mock('../../lib/api-fetch', () => ({ apiFetch: vi.fn(), })); +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + import { apiFetch } from '../../lib/api-fetch'; import { useTodoData } from '../useTodoData'; diff --git a/frontend/src/hooks/__tests__/useVoice.test.ts b/frontend/src/hooks/__tests__/useVoice.test.ts index ff979d3b..63bcff05 100644 --- a/frontend/src/hooks/__tests__/useVoice.test.ts +++ b/frontend/src/hooks/__tests__/useVoice.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useVoice } from '../useVoice'; -import { YAPPER_HEALTH_POLL_MS } from '../../lib/constants'; // --- Mocks --- @@ -58,7 +57,18 @@ vi.mock('../../lib/tts', () => ({ unlockAudioContext: vi.fn(() => Promise.resolve()), })); -// Mock fetch +// Mock useServiceHealth — control yapper status per test +let mockYapper: { ok: boolean; detail?: Record } | null = null; +vi.mock('../useServiceHealth', () => ({ + useServiceHealth: () => ({ + services: mockYapper ? [mockYapper] : [], + yapper: mockYapper, + contexgin: null, + checkedAt: mockYapper ? Date.now() : 0, + }), +})); + +// Mock fetch (still needed for transcription + voice list) const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -76,82 +86,52 @@ beforeEach(() => { }); mockGetUserMedia.mockResolvedValue(mockStream); mockFetch.mockReset(); + mockYapper = null; }); afterEach(() => { vi.restoreAllMocks(); }); -// Helper: mock Yapper health response -function mockHealthy(stt = true) { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ status: 'ready', models: { stt, tts: false } }), - }); -} - -function mockUnhealthy() { - mockFetch.mockRejectedValueOnce(new Error('Network error')); -} - // --- Tests --- describe('useVoice', () => { - describe('health polling', () => { - it('sets available=true when Yapper is healthy with STT ready', async () => { - mockHealthy(); + describe('SSE-driven health', () => { + it('sets available=true when yapper is healthy with STT ready', () => { + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - - await waitFor(() => { - expect(result.current.available).toBe(true); - }); + expect(result.current.available).toBe(true); }); - it('sets available=false when Yapper is unreachable', async () => { - mockUnhealthy(); + it('sets available=false when yapper is null', () => { + mockYapper = null; const { result } = renderHook(() => useVoice()); - - await waitFor(() => { - expect(result.current.available).toBe(false); - }); + expect(result.current.available).toBe(false); }); - it('sets available=false when STT model is not ready', async () => { - mockHealthy(false); + it('sets available=false when STT model is not ready', () => { + mockYapper = { ok: true, detail: { stt: false, tts: true } }; const { result } = renderHook(() => useVoice()); - - await waitFor(() => { - expect(result.current.available).toBe(false); - }); + expect(result.current.available).toBe(false); }); - it('polls health periodically', async () => { - vi.useFakeTimers(); - mockHealthy(); - renderHook(() => useVoice()); - - // Initial fetch - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // After poll interval - mockHealthy(); - await act(async () => { - await vi.advanceTimersByTimeAsync(YAPPER_HEALTH_POLL_MS); - }); - expect(mockFetch).toHaveBeenCalledTimes(2); + it('sets ttsAvailable from yapper detail', () => { + mockYapper = { ok: true, detail: { stt: true, tts: true } }; + const { result } = renderHook(() => useVoice()); + expect(result.current.ttsAvailable).toBe(true); + }); - vi.useRealTimers(); + it('ttsAvailable is false when models.tts is false', () => { + mockYapper = { ok: true, detail: { stt: true, tts: false } }; + const { result } = renderHook(() => useVoice()); + expect(result.current.ttsAvailable).toBe(false); }); }); describe('recording', () => { it('starts recording after requesting mic permission', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -162,10 +142,9 @@ describe('useVoice', () => { }); it('sets micBlocked when permission is denied', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; mockGetUserMedia.mockRejectedValueOnce(new DOMException('denied', 'NotAllowedError')); const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -176,9 +155,8 @@ describe('useVoice', () => { }); it('stops recording and returns transcript', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -190,7 +168,6 @@ describe('useVoice', () => { stopPromise = result.current.stopRecording(); }); - // Simulate final transcript from WS act(() => { mockWsClient.onTranscript?.({ type: 'final', text: 'hello world' }); }); @@ -204,9 +181,8 @@ describe('useVoice', () => { }); it('cancel discards recording without transcribing', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -217,7 +193,6 @@ describe('useVoice', () => { }); expect(result.current.recording).toBe(false); - // No transcription fetch should have been made (only health check) const transcribeCalls = mockFetch.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].includes('/v1/transcribe'), ); @@ -227,21 +202,18 @@ describe('useVoice', () => { describe('transcription errors', () => { it('returns empty string on batch transcription failure', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); - // Simulate WS error so it falls back to batch act(() => { mockWsClient.onError?.(new Event('error')); }); - // Mock batch transcription failure mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); let transcript: string | undefined; @@ -256,29 +228,25 @@ describe('useVoice', () => { describe('streaming STT', () => { it('uses streaming recorder + WS client when available', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); expect(result.current.recording).toBe(true); - // Streaming recorder should have been started expect(mockStreamingRecorder.start).toHaveBeenCalled(); }); it('shows partial transcript from WS partial events', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); - // Simulate a partial transcript from the WS act(() => { mockWsClient.onTranscript?.({ type: 'partial', text: 'hello' }); }); @@ -287,9 +255,8 @@ describe('useVoice', () => { }); it('updates partial transcript on each new partial', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -307,26 +274,22 @@ describe('useVoice', () => { }); it('stopRecording sends END and returns final transcript', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); - // Simulate partials act(() => { mockWsClient.onTranscript?.({ type: 'partial', text: 'hello' }); }); - // Stop recording — should send END and wait for final let transcriptPromise: Promise; act(() => { transcriptPromise = result.current.stopRecording(); }); - // Simulate the final transcript arriving act(() => { mockWsClient.onTranscript?.({ type: 'final', text: 'hello world' }); }); @@ -342,19 +305,16 @@ describe('useVoice', () => { }); it('sends audio chunks to WS as they arrive', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); - // Simulate a chunk from the streaming recorder const chunkBlob = new Blob(['audio-chunk'], { type: 'audio/webm' }); await act(async () => { mockStreamingRecorder.onChunk?.(chunkBlob); - // Give it a tick for the async arrayBuffer conversion await new Promise((r) => setTimeout(r, 0)); }); @@ -362,9 +322,8 @@ describe('useVoice', () => { }); it('sends format frame before audio', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -374,9 +333,8 @@ describe('useVoice', () => { }); it('cancelRecording closes WS and clears partial', async () => { - mockHealthy(); + mockYapper = { ok: true, detail: { stt: true, tts: false } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); @@ -398,26 +356,22 @@ describe('useVoice', () => { }); it('falls back to batch on WS error during recording', async () => { - mockHealthy(); - // Mock the batch transcription response for fallback + mockYapper = { ok: true, detail: { stt: true, tts: false } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ text: 'batch fallback', language: 'en', duration: 1.0 }), }); const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.available).toBe(true)); await act(async () => { await result.current.startRecording(); }); - // Simulate WS error act(() => { mockWsClient.onError?.(new Event('error')); }); - // stopRecording should use batch fallback let transcript: string | undefined; await act(async () => { transcript = await result.current.stopRecording(); @@ -428,44 +382,14 @@ describe('useVoice', () => { }); describe('TTS', () => { - // Helper: health with TTS available - function mockHealthyWithTts() { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ status: 'ready', models: { stt: true, tts: true } }), - }); - } - - it('sets ttsAvailable from health poll models.tts', async () => { - mockHealthyWithTts(); + it('ttsEnabled defaults to false', () => { + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - - await waitFor(() => { - expect(result.current.ttsAvailable).toBe(true); - }); - }); - - it('ttsAvailable is false when models.tts is false', async () => { - mockHealthy(); // stt=true, tts=false - const { result } = renderHook(() => useVoice()); - - await waitFor(() => { - expect(result.current.available).toBe(true); - expect(result.current.ttsAvailable).toBe(false); - }); - }); - - it('ttsEnabled defaults to false', async () => { - mockHealthyWithTts(); - const { result } = renderHook(() => useVoice()); - - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); expect(result.current.ttsEnabled).toBe(false); }); it('setTtsEnabled toggles and persists to localStorage', async () => { - mockHealthyWithTts(); - // Mock voices fetch when enabling TTS + mockYapper = { ok: true, detail: { stt: true, tts: true } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => @@ -477,7 +401,6 @@ describe('useVoice', () => { }); const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); await act(async () => { result.current.setTtsEnabled(true); @@ -488,17 +411,14 @@ describe('useVoice', () => { }); it('fetches voices lazily on first setTtsEnabled(true)', async () => { - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); - // No voices fetch yet const voiceFetchesBefore = mockFetch.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].includes('/v1/voices'), ); expect(voiceFetchesBefore).toHaveLength(0); - // Enable TTS — should fetch voices mockFetch.mockResolvedValueOnce({ ok: true, json: () => @@ -525,25 +445,20 @@ describe('useVoice', () => { const mockUnlock = unlockAudioContext as ReturnType; mockUnlock.mockClear(); - // Simulate suspended AudioContext (page load, no user gesture yet) mockCtx.mockReturnValue({ state: 'suspended' }); - // Pre-set ttsEnabled in localStorage before render localStorage.setItem('mitzo-tts-enabled', 'true'); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; renderHook(() => useVoice()); await waitFor(() => {}); - // unlockAudioContext should NOT have been called yet (no user gesture) expect(mockUnlock).not.toHaveBeenCalled(); - // Simulate user click — should trigger the one-shot listener document.dispatchEvent(new Event('click')); expect(mockUnlock).toHaveBeenCalledTimes(1); - // Restore default mock mockCtx.mockReturnValue({ state: 'running' }); }); @@ -556,16 +471,14 @@ describe('useVoice', () => { mockCtx.mockReturnValue({ state: 'suspended' }); localStorage.setItem('mitzo-tts-enabled', 'true'); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; renderHook(() => useVoice()); await waitFor(() => {}); - // Simulate touch — the primary gesture on iOS Safari document.dispatchEvent(new Event('touchstart')); expect(mockUnlock).toHaveBeenCalledTimes(1); - // Second touch should NOT call again (listener removed) mockUnlock.mockClear(); document.dispatchEvent(new Event('touchstart')); expect(mockUnlock).not.toHaveBeenCalled(); @@ -579,24 +492,21 @@ describe('useVoice', () => { const mockUnlock = unlockAudioContext as ReturnType; mockUnlock.mockClear(); - // AudioContext already running (not suspended) mockCtx.mockReturnValue({ state: 'running' }); localStorage.setItem('mitzo-tts-enabled', 'true'); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; renderHook(() => useVoice()); await waitFor(() => {}); - // No listener should be registered — click should NOT call unlock document.dispatchEvent(new Event('click')); expect(mockUnlock).not.toHaveBeenCalled(); }); it('setTtsEnabled(true) calls unlockAudioContext for iOS Safari', async () => { const { unlockAudioContext } = await import('../../lib/tts'); - mockHealthyWithTts(); - // Mock voices fetch + mockYapper = { ok: true, detail: { stt: true, tts: true } }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => @@ -608,7 +518,6 @@ describe('useVoice', () => { }); const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); await act(async () => { result.current.setTtsEnabled(true); @@ -619,9 +528,8 @@ describe('useVoice', () => { it('speak() synthesizes and plays audio', async () => { const { synthesize, playAudio } = await import('../../lib/tts'); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); await act(async () => { await result.current.speak('Hello world'); @@ -633,7 +541,6 @@ describe('useVoice', () => { }); it('stopSpeaking() stops current playback', async () => { - // Make play() hang so we can interrupt it let resolvePlay!: () => void; mockPlayHandle.play.mockImplementationOnce( () => @@ -642,11 +549,9 @@ describe('useVoice', () => { }), ); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); - // Start speaking (will hang on play) let speakDone = false; act(() => { result.current.speak('Hello world').then(() => { @@ -654,10 +559,8 @@ describe('useVoice', () => { }); }); - // Wait for speaking state await waitFor(() => expect(result.current.speaking).toBe(true)); - // Now stop act(() => { result.current.stopSpeaking(); }); @@ -665,7 +568,6 @@ describe('useVoice', () => { expect(mockPlayHandle.stop).toHaveBeenCalled(); expect(result.current.speaking).toBe(false); - // Clean up the hanging promise resolvePlay(); await waitFor(() => expect(speakDone).toBe(true)); }); @@ -676,36 +578,30 @@ describe('useVoice', () => { const mockSynth = synthesize as ReturnType; const mockPlayAudio = playAudio as ReturnType; - // Clear any calls from previous tests mockSynth.mockClear(); mockPlayAudio.mockClear(); - // 3 chunks: first succeeds, second fails, third succeeds mockChunk.mockReturnValueOnce(['chunk1', 'chunk2', 'chunk3']); const goodBlob = new Blob(['wav'], { type: 'audio/wav' }); mockSynth - .mockResolvedValueOnce(goodBlob) // chunk1 ok - .mockRejectedValueOnce(new Error('Synthesis failed (500)')) // chunk2 fails - .mockResolvedValueOnce(goodBlob); // chunk3 ok + .mockResolvedValueOnce(goodBlob) + .mockRejectedValueOnce(new Error('Synthesis failed (500)')) + .mockResolvedValueOnce(goodBlob); - mockHealthyWithTts(); + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); await act(async () => { await result.current.speak('some long text'); }); - // synthesize called 3 times (didn't abort after failure) expect(mockSynth).toHaveBeenCalledTimes(3); - // playAudio called for chunk1 and chunk3 (skipped chunk2) expect(mockPlayAudio).toHaveBeenCalledTimes(2); }); - it('setVoice persists to localStorage', async () => { - mockHealthyWithTts(); + it('setVoice persists to localStorage', () => { + mockYapper = { ok: true, detail: { stt: true, tts: true } }; const { result } = renderHook(() => useVoice()); - await waitFor(() => expect(result.current.ttsAvailable).toBe(true)); act(() => { result.current.setVoice('am_adam'); diff --git a/frontend/src/hooks/useAttentionFeed.ts b/frontend/src/hooks/useAttentionFeed.ts new file mode 100644 index 00000000..60166365 --- /dev/null +++ b/frontend/src/hooks/useAttentionFeed.ts @@ -0,0 +1,230 @@ +import { useState, useEffect, useMemo } from 'react'; +import { apiFetch } from '../lib/api-fetch'; +import { eventBus } from '../lib/event-bus-singleton'; +import { useMitzoStore } from '@mitzo/client/hooks'; +import type { SessionActivity } from '@mitzo/protocol'; +import type { TodoItem } from '../types/todo'; +import type { Task, TaskStatus } from '../types/task'; + +// ─── Attention item model ────────────────────────────────────────────────── + +export type AttentionSource = 'telos' | 'atb' | 'session'; +export type AttentionTier = 1 | 2; + +export interface AttentionItem { + id: string; + source: AttentionSource; + tier: AttentionTier; + title: string; + meta: string; + accentColor: string; + icon: string; + /** Navigation target */ + navigateTo: string; + /** Epoch ms used for recency sort within a tier (higher = more recent) */ + updatedAt: number; +} + +// ─── Color constants ─────────────────────────────────────────────────────── + +const COLOR_AMBER = '#fbbf24'; +const COLOR_RED = '#ff6d6d'; +const COLOR_PURPLE = '#b48cff'; +const COLOR_GREEN = '#4ade80'; + +// ─── Derive attention items from Telos ───────────────────────────────────── + +function telosToAttention(items: TodoItem[]): AttentionItem[] { + const result: AttentionItem[] = []; + for (const item of items) { + // ageDays → approximate epoch (lower ageDays = more recent) + const updatedAt = Date.now() - item.ageDays * 86_400_000; + // T1: starred + high urgency = Focus + if (item.starred && item.urgency >= 0.5) { + result.push({ + id: `telos-${item.id}`, + source: 'telos', + tier: 1, + title: item.summary, + meta: `${item.ageDays === 0 ? 'new' : `${item.ageDays}d`} · ${item.profile}`, + accentColor: item.urgency >= 0.8 ? COLOR_RED : COLOR_AMBER, + icon: '\u2605', // ★ + navigateTo: `/todos/${item.id}`, + updatedAt, + }); + } + // T2: starred but lower urgency, or unstarred but high urgency + else if (item.starred || item.urgency >= 0.8) { + result.push({ + id: `telos-${item.id}`, + source: 'telos', + tier: 2, + title: item.summary, + meta: `${item.ageDays === 0 ? 'new' : `${item.ageDays}d`} · ${item.profile}`, + accentColor: item.starred ? COLOR_AMBER : COLOR_PURPLE, + icon: item.starred ? '\u2605' : '\u25CF', // ★ or ● + navigateTo: `/todos/${item.id}`, + updatedAt, + }); + } + } + return result; +} + +// ─── Derive attention items from ATB ─────────────────────────────────────── + +const ATB_TIER1_STATUSES: TaskStatus[] = ['pending_review', 'blocked', 'failed']; + +function flattenTasks(tasks: Task[]): Task[] { + const result: Task[] = []; + for (const t of tasks) { + result.push(t); + if (t.children.length > 0) result.push(...flattenTasks(t.children)); + } + return result; +} + +function atbToAttention(tasks: Task[]): AttentionItem[] { + const flat = flattenTasks(tasks); + return flat + .filter((t) => ATB_TIER1_STATUSES.includes(t.status)) + .map((t) => ({ + id: `atb-${t.id}`, + source: 'atb' as AttentionSource, + tier: 1 as AttentionTier, + title: t.title, + meta: + t.status === 'pending_review' + ? 'awaiting approval' + : t.status === 'blocked' + ? 'blocked' + : 'failed', + accentColor: t.status === 'pending_review' ? COLOR_AMBER : COLOR_RED, + icon: + t.status === 'pending_review' + ? '\u25D4' // ◔ + : t.status === 'blocked' + ? '\u2298' // ⊘ + : '\u2717', // ✗ + navigateTo: '/tasks', + updatedAt: t.updatedAt || 0, + })); +} + +// ─── Derive attention items from sessions ────────────────────────────────── + +function sessionsToAttention(activities: SessionActivity[]): AttentionItem[] { + return activities + .filter((a) => a.state === 'waiting' || a.state === 'done') + .map((a) => ({ + id: `session-${a.sessionId}`, + source: 'session' as AttentionSource, + tier: (a.state === 'waiting' ? 1 : 2) as AttentionTier, + title: a.title, + meta: + a.state === 'waiting' + ? a.waitReason === 'permission' + ? 'permission needed' + : a.waitReason === 'review' + ? 'review needed' + : 'waiting' + : 'done', + accentColor: a.state === 'waiting' ? COLOR_RED : COLOR_GREEN, + icon: a.state === 'waiting' ? '\u26A0' : '\u2713', // ⚠ or ✓ + navigateTo: `/chat/${a.sessionId}`, + updatedAt: a.lastEventAt || 0, + })); +} + +// ─── Sort by tier then recency ───────────────────────────────────────────── + +function sortAttention(items: AttentionItem[]): AttentionItem[] { + return [...items].sort((a, b) => a.tier - b.tier || b.updatedAt - a.updatedAt); +} + +// ─── Hook ────────────────────────────────────────────────────────────────── + +const MAX_ITEMS = 5; + +export interface UseAttentionFeedReturn { + items: AttentionItem[]; + tier1Count: number; + loading: boolean; +} + +export function useAttentionFeed(): UseAttentionFeedReturn { + // Telos data — lightweight fetch (no profile filter = all) + const [todos, setTodos] = useState([]); + const [todosLoading, setTodosLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + apiFetch('/api/todos') + .then((r) => (r.ok ? r.json() : { items: [] })) + .then((data: { items: TodoItem[] }) => { + if (!cancelled) { + setTodos(data.items); + setTodosLoading(false); + } + }) + .catch(() => { + if (!cancelled) setTodosLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refreshKey]); + + // SSE-driven refreshes + useEffect(() => { + const unsub = eventBus.on('todo_update', () => setRefreshKey((k) => k + 1)); + return unsub; + }, []); + + // ATB tasks from store + const tasks = useMitzoStore((s) => s.tasks.tree); + const loadTasks = useMitzoStore((s) => s.loadTasks); + + useEffect(() => { + loadTasks(); + }, [loadTasks]); + + useEffect(() => { + const unsub = eventBus.on('task_state', () => loadTasks()); + return unsub; + }, [loadTasks]); + + // Session activities from SSE — validated at runtime + const [activities, setActivities] = useState([]); + useEffect(() => { + const unsub = eventBus.on('session_activity', (data) => { + if (!Array.isArray(data)) return; + const valid = data.filter( + (d): d is SessionActivity => + d != null && + typeof d === 'object' && + typeof (d as Record).sessionId === 'string' && + typeof (d as Record).state === 'string', + ); + setActivities(valid); + }); + return unsub; + }, []); + + const feed = useMemo(() => { + const telosItems = telosToAttention(todos); + const atbItems = atbToAttention(tasks); + const sessionItems = sessionsToAttention(activities); + const all = sortAttention([...telosItems, ...atbItems, ...sessionItems]); + return all.slice(0, MAX_ITEMS); + }, [todos, tasks, activities]); + + const tier1Count = useMemo(() => feed.filter((i) => i.tier === 1).length, [feed]); + + return { + items: feed, + tier1Count, + loading: todosLoading, + }; +} diff --git a/frontend/src/hooks/useDocumentReader.ts b/frontend/src/hooks/useDocumentReader.ts index 8b9f82cb..024cdbc9 100644 --- a/frontend/src/hooks/useDocumentReader.ts +++ b/frontend/src/hooks/useDocumentReader.ts @@ -1,11 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - YAPPER_URL, - YAPPER_HEALTH_POLL_MS, - DEFAULT_TTS_VOICE, - DOCUMENT_READ_MAX_CHARS, -} from '../lib/constants'; +import { YAPPER_URL, DEFAULT_TTS_VOICE, DOCUMENT_READ_MAX_CHARS } from '../lib/constants'; import { synthesizeDocument, playAudio, unlockAudioContext } from '../lib/tts'; +import { useServiceHealth } from './useServiceHealth'; export type ReaderState = 'idle' | 'loading' | 'playing'; @@ -21,37 +17,12 @@ export interface DocumentReader { * Checks Yapper TTS availability and manages document playback lifecycle. */ export function useDocumentReader(): DocumentReader { - const [available, setAvailable] = useState(false); + const { yapper } = useServiceHealth(); + const available = yapper?.ok === true && yapper.detail?.tts !== false; const [state, setState] = useState('idle'); const abortRef = useRef(null); const playRef = useRef<{ stop: () => void } | null>(null); - // Health poll — reuses same pattern as useVoice - useEffect(() => { - let mounted = true; - async function check() { - try { - const res = await fetch(`${YAPPER_URL}/health`); - if (!res.ok) { - if (mounted) setAvailable(false); - return; - } - const data = await res.json(); - const ready = data.status === 'ready' || data.status === 'ok'; - const tts = data.models ? data.models.tts === true : ready; - if (mounted) setAvailable(ready && tts); - } catch { - if (mounted) setAvailable(false); - } - } - check(); - const timer = setInterval(check, YAPPER_HEALTH_POLL_MS); - return () => { - mounted = false; - clearInterval(timer); - }; - }, []); - const stop = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; diff --git a/frontend/src/hooks/useServiceHealth.ts b/frontend/src/hooks/useServiceHealth.ts new file mode 100644 index 00000000..1d2b571a --- /dev/null +++ b/frontend/src/hooks/useServiceHealth.ts @@ -0,0 +1,101 @@ +// SSE-driven service health with REST polling fallback. +// iOS WebKit can't establish SSE over self-signed HTTPS, so we poll /api/service-health too. + +import { useState, useEffect, useMemo, useRef } from 'react'; +import { eventBus } from '../lib/event-bus-singleton'; +import { apiFetch } from '../lib/api-fetch'; +import type { ServiceHealthPayload, ServiceHealthStatus } from '@mitzo/protocol'; + +const POLL_INTERVAL_MS = 30_000; +const SSE_FALLBACK_DELAY_MS = 2_000; // Reduced from 5s for faster iOS fallback +const CACHE_KEY = 'mitzo:service-health'; + +export interface UseServiceHealthReturn { + services: ServiceHealthStatus[]; + yapper: ServiceHealthStatus | null; + contexgin: ServiceHealthStatus | null; + checkedAt: number; +} + +// Load cached health on boot for instant mic button availability +function getCachedHealth(): ServiceHealthPayload { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + return JSON.parse(cached) as ServiceHealthPayload; + } + } catch { + // ignore parse errors + } + return { services: [], checkedAt: 0 }; +} + +export function useServiceHealth(): UseServiceHealthReturn { + const [payload, setPayload] = useState(getCachedHealth); + const gotSseEvent = useRef(false); + + // SSE listener (primary — works on Chrome, may fail on iOS) + useEffect(() => { + return eventBus.on('health', (data) => { + gotSseEvent.current = true; + const healthData = data as ServiceHealthPayload; + setPayload(healthData); + // Cache for next session + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(healthData)); + } catch { + // ignore quota errors + } + }); + }, []); + + // REST polling fallback — kicks in if SSE hasn't delivered after 2s + useEffect(() => { + let timer: ReturnType | null = null; + let cancelled = false; + + const poll = async () => { + try { + const res = await apiFetch('/api/service-health'); + if (res.ok && !cancelled) { + const data = (await res.json()) as ServiceHealthPayload; + setPayload(data); + // Cache for next session + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(data)); + } catch { + // ignore quota errors + } + } + } catch { + // ignore — server unreachable + } + }; + + // Wait 2s, then check if SSE has delivered. If not, start polling. + const startup = setTimeout(() => { + if (cancelled) return; + if (!gotSseEvent.current) { + poll(); // immediate first poll + timer = setInterval(poll, POLL_INTERVAL_MS); + } + }, SSE_FALLBACK_DELAY_MS); + + return () => { + cancelled = true; + clearTimeout(startup); + if (timer) clearInterval(timer); + }; + }, []); + + const yapper = useMemo( + () => payload.services.find((s) => s.name === 'yapper') ?? null, + [payload], + ); + const contexgin = useMemo( + () => payload.services.find((s) => s.name === 'contexgin') ?? null, + [payload], + ); + + return { services: payload.services, yapper, contexgin, checkedAt: payload.checkedAt }; +} diff --git a/frontend/src/hooks/useSessionList.ts b/frontend/src/hooks/useSessionList.ts index 41459443..e70799be 100644 --- a/frontend/src/hooks/useSessionList.ts +++ b/frontend/src/hooks/useSessionList.ts @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { Session } from '../types/chat'; import { renameSession as renameSessionApi } from '../lib/rename-session'; import { apiFetch } from '../lib/api-fetch'; +import { eventBus } from '../lib/event-bus-singleton'; +import type { SessionActivity } from './useSessionOverview'; export interface QuickAction { label: string; @@ -86,8 +88,24 @@ export function useSessionList(): UseSessionListReturn { }; document.addEventListener('visibilitychange', onVisible); + // Live session dots via SSE — update isActive/isAttached without full refetch + const unsubActivity = eventBus.on('session_activity', (data) => { + const activities = data as SessionActivity[]; + const activeIds = new Set(activities.map((a) => a.sessionId)); + setSessions((prev) => + prev.map((s) => ({ + ...s, + isActive: activeIds.has(s.id), + isAttached: activities.some( + (a) => a.sessionId === s.id && a.state !== 'idle' && a.state !== 'done', + ), + })), + ); + }); + return () => { document.removeEventListener('visibilitychange', onVisible); + unsubActivity(); }; }, []); diff --git a/frontend/src/hooks/useSessionOverview.ts b/frontend/src/hooks/useSessionOverview.ts new file mode 100644 index 00000000..f6ece98d --- /dev/null +++ b/frontend/src/hooks/useSessionOverview.ts @@ -0,0 +1,60 @@ +import { useState, useEffect, useMemo } from 'react'; +import { eventBus } from '../lib/event-bus-singleton'; +import type { SessionActivity } from '@mitzo/protocol'; + +export type { SessionActivity, SessionActivityState, WaitReason } from '@mitzo/protocol'; + +// ─── Tier sorting ─────────────────────────────────────────────────────────── + +type AttendTier = 1 | 2 | 3 | 4; + +function getTier(activity: SessionActivity): AttendTier { + if (activity.state === 'waiting') return 1; + if (activity.state === 'done') return 2; + if (activity.state === 'working') return 3; + return 4; // init, idle, paused +} + +function sortByTier(activities: SessionActivity[]): SessionActivity[] { + return [...activities].sort((a, b) => { + const tierDiff = getTier(a) - getTier(b); + if (tierDiff !== 0) return tierDiff; + // Within same tier, most recent first + return b.lastEventAt - a.lastEventAt; + }); +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export interface UseSessionOverviewReturn { + /** All active sessions, sorted by attention tier. */ + activities: SessionActivity[]; + /** Number of Tier 1 (needs you) sessions. */ + attendCount: number; + /** Whether SSE is connected. */ + connected: boolean; +} + +export function useSessionOverview(): UseSessionOverviewReturn { + const [activities, setActivities] = useState([]); + const [connected, setConnected] = useState(eventBus.connected); + + useEffect(() => { + const unsubActivity = eventBus.on('session_activity', (data) => { + setActivities(data as SessionActivity[]); + }); + const unsubConnection = eventBus.onConnectionChange((c) => setConnected(c)); + return () => { + unsubActivity(); + unsubConnection(); + }; + }, []); + + const sorted = useMemo(() => sortByTier(activities), [activities]); + const attendCount = useMemo( + () => activities.filter((a) => a.state === 'waiting').length, + [activities], + ); + + return { activities: sorted, attendCount, connected }; +} diff --git a/frontend/src/hooks/useTabBadges.ts b/frontend/src/hooks/useTabBadges.ts index 0fff1b4b..00a84b05 100644 --- a/frontend/src/hooks/useTabBadges.ts +++ b/frontend/src/hooks/useTabBadges.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react'; import { useMitzoStore } from '@mitzo/client/hooks'; +import { eventBus } from '../lib/event-bus-singleton'; export interface TabBadges { inboxCount: number; @@ -23,7 +24,15 @@ export function useTabBadges(): TabBadges { } }; document.addEventListener('visibilitychange', onVisible); - return () => document.removeEventListener('visibilitychange', onVisible); + + const unsubInbox = eventBus.on('inbox_updated', () => loadInbox()); + const unsubTodo = eventBus.on('todo_update', () => loadTodos()); + + return () => { + document.removeEventListener('visibilitychange', onVisible); + unsubInbox(); + unsubTodo(); + }; }, [loadInbox, loadTodos]); const todoCount = useMemo( diff --git a/frontend/src/hooks/useTaskBoard.ts b/frontend/src/hooks/useTaskBoard.ts index cfca74ff..2806071a 100644 --- a/frontend/src/hooks/useTaskBoard.ts +++ b/frontend/src/hooks/useTaskBoard.ts @@ -1,6 +1,113 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useMitzoStore } from '@mitzo/client/hooks'; -import type { Task, LoopStatus } from '../types/task'; +import type { Task, TaskStatus, LoopStatus } from '../types/task'; +import { eventBus } from '../lib/event-bus-singleton'; +import { formatRelativeTime, formatDuration } from '../lib/formatTime'; + +// ─── Display Meta ────────────────────────────────────────────────────────── + +export interface TaskDisplayMeta { + attendTier: 1 | 2 | 3 | 4; + fadeOpacity: number; + completedAgo?: string; + elapsedLabel?: string; + sessionHash?: string; + blockerSummary?: string; +} + +// ─── Pure helpers ────────────────────────────────────────────────────────── + +const FADE_START_MS = 5 * 60 * 1000; // 5 minutes +const FADE_END_MS = 30 * 60 * 1000; // 30 minutes + +function getAttendTier(status: TaskStatus): 1 | 2 | 3 | 4 { + switch (status) { + case 'pending_review': + case 'blocked': + case 'failed': + return 1; + case 'done': + return 2; + case 'active': + return 3; + default: + return 4; + } +} + +function computeFadeOpacity(completedAt: number | null, now: number): number { + if (!completedAt) return 1; + const elapsed = now - completedAt; + if (elapsed < FADE_START_MS) return 1; + if (elapsed >= FADE_END_MS) return 0; + // Linear fade from 1 → 0.5 between 5min and 30min + return 0.5 + 0.5 * (1 - (elapsed - FADE_START_MS) / (FADE_END_MS - FADE_START_MS)); +} + +function buildDisplayMeta(task: Task, now: number): TaskDisplayMeta { + const tier = getAttendTier(task.status); + const meta: TaskDisplayMeta = { + attendTier: tier, + fadeOpacity: task.status === 'done' ? computeFadeOpacity(task.completedAt, now) : 1, + }; + + if (task.status === 'done' && task.completedAt) { + meta.completedAgo = formatRelativeTime(task.completedAt); + } + if (task.status === 'active' && task.claimedAt) { + meta.elapsedLabel = formatDuration(now - task.claimedAt); + } + if (task.sessionId) { + meta.sessionHash = task.sessionId.slice(0, 6); + } + if ((task.status === 'blocked' || task.status === 'failed') && task.annotations.length > 0) { + meta.blockerSummary = task.annotations[0]; + } + + return meta; +} + +function collectDisplayMeta(tasks: Task[], now: number, map: Map): void { + for (const task of tasks) { + map.set(task.id, buildDisplayMeta(task, now)); + if (task.children.length > 0) { + collectDisplayMeta(task.children, now, map); + } + } +} + +function sortByAttendTier(tasks: Task[]): Task[] { + return [...tasks].sort((a, b) => { + const tierA = getAttendTier(a.status); + const tierB = getAttendTier(b.status); + if (tierA !== tierB) return tierA - tierB; + + // Within same tier, secondary sort + if (tierA === 1) { + // T1: oldest first (needs attention longest) + return a.updatedAt - b.updatedAt; + } + if (tierA === 2) { + // T2: newest first (most recent completion on top) + return (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt); + } + // T3, T4: preserve tree position (stable sort by priority then creation) + return a.priority - b.priority || a.createdAt - b.createdAt; + }); +} + +function sumTokenUsage(tasks: Task[]): number { + let total = 0; + for (const task of tasks) { + total += task.tokenUsage; + if (task.children.length > 0) { + total += sumTokenUsage(task.children); + } + } + return total; +} + +// ─── Input/Result types ──────────────────────────────────────────────────── export interface TaskCreateInput { title: string; @@ -25,6 +132,11 @@ export interface TaskUpdateInput { export interface UseTaskBoardResult { loading: boolean; tasks: Task[]; + sortedTasks: Task[]; + displayMeta: Map; + totalTokenUsage: number; + showAll: boolean; + setShowAll: (v: boolean) => void; loopStatus: LoopStatus; createTask: (input: TaskCreateInput) => Promise; updateTask: (id: string, input: TaskUpdateInput) => Promise; @@ -40,8 +152,13 @@ export interface UseTaskBoardResult { refresh: () => void; } +// ─── Hook ────────────────────────────────────────────────────────────────── + export function useTaskBoard(): UseTaskBoardResult { const [loading, setLoading] = useState(true); + const [showAll, setShowAll] = useState(false); + const [now, setNow] = useState(Date.now); + const tasks = useMitzoStore((s) => s.tasks.tree); const loopStatus = useMitzoStore((s) => s.tasks.loopStatus); const loadTasks = useMitzoStore((s) => s.loadTasks); @@ -63,9 +180,48 @@ export function useTaskBoard(): UseTaskBoardResult { Promise.all([loadTasks(), loadLoopStatus()]).finally(() => setLoading(false)); }, [loadTasks, loadLoopStatus]); + // Live updates via SSE + useEffect(() => { + const unsubLoop = eventBus.on('loop_status', () => { + loadLoopStatus(); + }); + const unsubTask = eventBus.on('task_state', () => { + refreshTasks(); + }); + return () => { + unsubLoop(); + unsubTask(); + }; + }, [loadLoopStatus, refreshTasks]); + + // Timer — recompute every 60s for done task fade opacity and active task elapsed time + useEffect(() => { + if (tasks.length === 0) return; + const interval = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(interval); + }, [tasks]); + + // Sorted tasks (root level only; children keep tree order) + const sortedTasks = useMemo(() => (showAll ? tasks : sortByAttendTier(tasks)), [tasks, showAll]); + + // Display meta for all tasks + const displayMeta = useMemo(() => { + const map = new Map(); + collectDisplayMeta(tasks, now, map); + return map; + }, [tasks, now]); + + // Total token usage + const totalTokenUsage = useMemo(() => sumTokenUsage(tasks), [tasks]); + return { loading, tasks, + sortedTasks, + displayMeta, + totalTokenUsage, + showAll, + setShowAll, loopStatus, createTask: useCallback( (input: TaskCreateInput) => storeCreateTask(input as unknown as Record), diff --git a/frontend/src/hooks/useTodoData.ts b/frontend/src/hooks/useTodoData.ts index dcd3bdc0..8140cdf3 100644 --- a/frontend/src/hooks/useTodoData.ts +++ b/frontend/src/hooks/useTodoData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { TodoItem, TodoData } from '../types/todo'; import { apiFetch } from '../lib/api-fetch'; +import { eventBus } from '../lib/event-bus-singleton'; export interface UseTodoDataResult { loading: boolean; @@ -83,6 +84,13 @@ export function useTodoData(profile?: string): UseTodoDataResult { }; }, [profile, refreshKey]); + // Live update: SSE todo_update signals a refetch + useEffect(() => { + return eventBus.on('todo_update', () => { + setRefreshKey((k) => k + 1); + }); + }, []); + const performAction = useCallback(async (id: string, action: string, days?: number) => { const body: Record = { action }; if (days !== undefined) body.days = days; diff --git a/frontend/src/hooks/useVoice.ts b/frontend/src/hooks/useVoice.ts index afa717c8..b418a9d8 100644 --- a/frontend/src/hooks/useVoice.ts +++ b/frontend/src/hooks/useVoice.ts @@ -1,13 +1,8 @@ // Voice integration hook — Yapper health, mic capture, streaming + batch transcription, TTS playback. import { useState, useEffect, useRef, useCallback } from 'react'; -import { - YAPPER_URL, - YAPPER_HEALTH_POLL_MS, - TTS_ENABLED_KEY, - TTS_VOICE_KEY, - DEFAULT_TTS_VOICE, -} from '../lib/constants'; +import { YAPPER_URL, TTS_ENABLED_KEY, TTS_VOICE_KEY, DEFAULT_TTS_VOICE } from '../lib/constants'; +import { useServiceHealth } from './useServiceHealth'; import { negotiateMimeType, createRecorder, @@ -26,11 +21,6 @@ import { closeAudioContext, } from '../lib/tts'; -interface YapperHealth { - status: string; - models?: { stt?: boolean; tts?: boolean }; -} - export interface Voice { id: string; name: string; @@ -76,15 +66,19 @@ function mimeToFormat(mime: string): string { } export function useVoice(): UseVoiceReturn { + // --- Service health (SSE-driven) --- + const { yapper } = useServiceHealth(); + // detail?.stt !== false: when Yapper omits `models`, detail is undefined + // and we assume both capabilities (matches old per-hook polling behavior). + // Server sets ok=false for non-ready statuses, so this only fires when healthy. + const available = yapper?.ok === true && yapper.detail?.stt !== false; + const ttsAvailable = yapper?.ok === true && yapper.detail?.tts !== false; + // --- STT state --- - const [available, setAvailable] = useState(false); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const [micBlocked, setMicBlocked] = useState(false); const [error, setError] = useState(null); - - // --- TTS state --- - const [ttsAvailable, setTtsAvailable] = useState(false); const [ttsEnabled, setTtsEnabledState] = useState( () => localStorage.getItem(TTS_ENABLED_KEY) === 'true', ); @@ -138,45 +132,6 @@ export function useVoice(): UseVoiceReturn { }; }, [ttsEnabled]); - // --- Health polling --- - useEffect(() => { - let mounted = true; - async function checkHealth() { - try { - const res = await fetch(`${YAPPER_URL}/health`); - if (!res.ok) { - if (mounted) { - setAvailable(false); - setTtsAvailable(false); - } - return; - } - const data: YapperHealth = await res.json(); - if (mounted) { - const isReady = data.status === 'ready' || data.status === 'ok'; - // Yapper may omit `models` — when status is ok, assume both capabilities - const stt = data.models ? data.models.stt === true : isReady; - const tts = data.models ? data.models.tts === true : isReady; - setAvailable(isReady && stt); - setTtsAvailable(isReady && tts); - } - } catch { - if (mounted) { - setAvailable(false); - setTtsAvailable(false); - } - } - } - - checkHealth(); - const timer = setInterval(checkHealth, YAPPER_HEALTH_POLL_MS); - - return () => { - mounted = false; - clearInterval(timer); - }; - }, []); - // --- Negotiate mime type once --- useEffect(() => { try { diff --git a/frontend/src/lib/__tests__/event-bus-singleton.test.ts b/frontend/src/lib/__tests__/event-bus-singleton.test.ts new file mode 100644 index 00000000..598a86a8 --- /dev/null +++ b/frontend/src/lib/__tests__/event-bus-singleton.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// Tests for the global SSE EventBus singleton's visibilitychange recovery. + +import { describe, it, expect, vi } from 'vitest'; + +// Mock EventSource (jsdom doesn't provide it) +class MockEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + readyState = MockEventSource.CONNECTING; + onopen: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + close = vi.fn(); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); +} + +// Must be set before module loads — static imports are hoisted above beforeAll +global.EventSource = MockEventSource as unknown as typeof EventSource; + +// Dynamic import so the module-level side effects run after EventSource is defined +const { eventBus } = await import('../event-bus-singleton'); + +describe('event-bus-singleton visibilitychange recovery', () => { + it('calls ensureConnected when page becomes visible', () => { + const ensureConnectedSpy = vi.spyOn(eventBus, 'ensureConnected'); + + // Simulate page becoming visible + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(ensureConnectedSpy).toHaveBeenCalled(); + ensureConnectedSpy.mockRestore(); + }); + + it('does not call ensureConnected when page becomes hidden', () => { + const ensureConnectedSpy = vi.spyOn(eventBus, 'ensureConnected'); + + // Simulate page becoming hidden + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(ensureConnectedSpy).not.toHaveBeenCalled(); + ensureConnectedSpy.mockRestore(); + }); +}); diff --git a/frontend/src/lib/biometric.ts b/frontend/src/lib/biometric.ts index 4f4ea4e6..0e902aa7 100644 --- a/frontend/src/lib/biometric.ts +++ b/frontend/src/lib/biometric.ts @@ -102,6 +102,9 @@ export async function biometricLogin(apiBaseUrl = ''): Promise { } localStorage.setItem(AUTH_TOKEN_KEY, token); + // Also save to native shared Keychain for Apple Watch + const { saveTokenToWatch } = await import('./watch-auth'); + await saveTokenToWatch(token); return token; } catch { return null; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index fa097e60..501aa92c 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -28,7 +28,6 @@ export const MAX_IMAGE_ATTACHMENTS = 4; // the page origin, so `new WebSocket('/api/yapper-ws/...')` works correctly. // In Capacitor, getApiBaseUrl() returns a full URL so the ws:// replace works. export const YAPPER_URL = import.meta.env.VITE_YAPPER_URL || `${getApiBaseUrl()}/api/yapper`; -export const YAPPER_HEALTH_POLL_MS = 30_000; export const MAX_RECORDING_DURATION_MS = 120_000; export const MIN_RECORDING_DURATION_MS = 500; export const TTS_CHUNK_MAX_CHARS = 500; diff --git a/frontend/src/lib/event-bus-singleton.ts b/frontend/src/lib/event-bus-singleton.ts new file mode 100644 index 00000000..90379ba4 --- /dev/null +++ b/frontend/src/lib/event-bus-singleton.ts @@ -0,0 +1,26 @@ +/** + * Global EventBus singleton for SSE events. + * + * Lazily connected on first import. Hooks subscribe via eventBus.on(). + * On iOS resume, ensureConnected() is called to recover from CLOSED state. + * On page visibility change, ensureConnected() reconnects if connection died. + */ + +import { EventBus } from '@mitzo/client'; +import { getApiBaseUrl } from './api-fetch'; + +export const eventBus = new EventBus(); + +// Connect immediately — EventSource auto-reconnects natively +const sseUrl = `${getApiBaseUrl()}/api/events`; +eventBus.connect(sseUrl); + +// Recover from dead SSE connections when page becomes visible again +// (e.g., iOS Safari backgrounding kills EventSource without firing error) +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + eventBus.ensureConnected(); + } + }); +} diff --git a/frontend/src/lib/formatTime.ts b/frontend/src/lib/formatTime.ts index e97e2ce3..2b3022a4 100644 --- a/frontend/src/lib/formatTime.ts +++ b/frontend/src/lib/formatTime.ts @@ -3,6 +3,15 @@ export function formatTime(ts?: number): string | null { return new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + export function formatRelativeTime(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60000); diff --git a/frontend/src/lib/watch-auth.ts b/frontend/src/lib/watch-auth.ts new file mode 100644 index 00000000..46e947a8 --- /dev/null +++ b/frontend/src/lib/watch-auth.ts @@ -0,0 +1,29 @@ +// Bridges web auth tokens into the native shared Keychain for Apple Watch. +// Calls the WatchAuthBridge Capacitor plugin on iOS; no-ops on web/Android. + +import { Capacitor, registerPlugin } from '@capacitor/core'; + +interface WatchAuthBridgePlugin { + saveToken(options: { token: string }): Promise; + clearToken(): Promise; +} + +const WatchAuthBridge = registerPlugin('WatchAuthBridge'); + +export async function saveTokenToWatch(token: string): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'ios') return; + try { + await WatchAuthBridge.saveToken({ token }); + } catch { + // Plugin not available or save failed — non-fatal + } +} + +export async function clearWatchToken(): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'ios') return; + try { + await WatchAuthBridge.clearToken(); + } catch { + // Plugin not available — non-fatal + } +} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index b2de198f..d93a223c 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -44,6 +44,7 @@ export function ChatView() { const storeRespondToPermission = useMitzoStore((s) => s.respondToPermission); const storeSwitchSession = useMitzoStore((s) => s.switchSession); const storeNewSession = useMitzoStore((s) => s.newSession); + const storeCloseSession = useMitzoStore((s) => s.closeSession); const storeSetMode = useMitzoStore((s) => s.setMode); const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); @@ -51,6 +52,7 @@ export function ChatView() { const pendingSession = useMitzoStore((s) => s.pendingSession); const clearPendingSession = useMitzoStore((s) => s.clearPendingSession); const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -132,7 +134,11 @@ export function ChatView() { // Set the context block for display storeDispatchMessages({ type: 'SET_SESSION_CONTEXT', context: pendingSession.context }); // Auto-send the prompt - storeSendMessage(pendingSession.prompt, { model: modelState, mode }); + storeSendMessage(pendingSession.prompt, { + model: modelState, + mode, + ...(pendingSession.telosTaskId ? { telosTaskId: pendingSession.telosTaskId } : {}), + }); clearPendingSession(); forceScrollToBottom(); }, [pendingSession]); // eslint-disable-line react-hooks/exhaustive-deps @@ -242,6 +248,15 @@ export function ChatView() { {isolation ? '\u{1f512}' : '\u{1f513}'} )} + {activeSessionId && ( + + )} diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index 11f23b5e..f0159119 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -1,10 +1,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { apiFetch } from '../lib/api-fetch'; import { DesktopShell } from '../components/DesktopShell'; import { SessionPanel } from '../components/SessionPanel'; -import { ContextPanel } from '../components/ContextPanel'; -import { FileBrowserPanel } from '../components/FileBrowserPanel'; +import { CommandCenter } from '../components/CommandCenter'; import { ChatArea } from '../components/ChatArea'; import { ChatInput } from '../components/ChatInput'; import { ScrollFab } from '../components/ScrollFab'; @@ -16,8 +14,6 @@ import { getPreferredModel, setPreferredModel } from '../lib/model-preference'; import { useVoice } from '../hooks/useVoice'; import { useAutoSpeak } from '../hooks/useAutoSpeak'; import { useProgressByToolId } from '../hooks/useProgress'; -import type { FileRoot } from '../components/FileBrowserPanel'; -import type { ContextBlockEntry } from '../components/ContextPicker'; import type { ImageAttachment } from '../types/chat'; export function DesktopChatView() { @@ -25,32 +21,6 @@ export function DesktopChatView() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [contextBlocks, setContextBlocks] = useState([]); - - // Shared config fetch for right-panel children - const [configBlocks, setConfigBlocks] = useState([]); - const [fileRoots, setFileRoots] = useState([]); - const [configLoaded, setConfigLoaded] = useState(false); - - useEffect(() => { - apiFetch('/api/config') - .then((r) => r.json()) - .then((data) => { - const entries: ContextBlockEntry[] = []; - if (data.contextBlocks) { - for (const [name, info] of Object.entries( - data.contextBlocks as Record, - )) { - entries.push({ name, path: info.path, sizeBytes: info.sizeBytes }); - } - } - setConfigBlocks(entries); - setFileRoots(data.fileViewerRoots ?? []); - setConfigLoaded(true); - }) - .catch(() => setConfigLoaded(true)); - }, []); - // Store state const messages = useMessages(); const connection = useConnection(); @@ -64,10 +34,13 @@ export function DesktopChatView() { const storeRespondToPermission = useMitzoStore((s) => s.respondToPermission); const storeSwitchSession = useMitzoStore((s) => s.switchSession); const storeNewSession = useMitzoStore((s) => s.newSession); + const storeCloseSession = useMitzoStore((s) => s.closeSession); const storeSetMode = useMitzoStore((s) => s.setMode); const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); const storeFetchSessionMeta = useMitzoStore((s) => s.fetchSessionMeta); + const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -149,10 +122,6 @@ export function DesktopChatView() { // ── Actions ────────────────────────────────────────────────────────────── function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean { - // For new sessions (no activeSessionId) the store bootstraps a WS on - // demand inside sendMessage(), so we must not block on connection status. - // Only gate on connection for existing sessions where a WS should already - // be open. if (activeSessionId && connection.status !== 'connected') { storeDispatchMessages({ type: 'CONNECTION_LOST' }); return false; @@ -195,14 +164,11 @@ export function DesktopChatView() { storeSetMode(newMode); } - const handleToggleContext = useCallback((name: string) => { - setContextBlocks((prev) => - prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], - ); - }, []); - const handleSelectSession = useCallback((id: string) => navigate(`/chat/${id}`), [navigate]); - const handleNewChat = useCallback(() => navigate('/chat'), [navigate]); + const handleNewChat = useCallback(() => { + storeNewSession(); + navigate('/chat'); + }, [navigate, storeNewSession]); return ( )} + {activeSessionId && ( + + )} @@ -288,22 +265,11 @@ export function DesktopChatView() { isWorktree={messages.isWorktree} wtId={messages.wtId || undefined} sessionId={activeSessionId ?? undefined} - externalContextBlocks={contextBlocks} tokenState={tokens} />

} - right={ -
- - -
- } + right={} statusBar={ + + + + + + {quickActions.length > 0 && (
+ @@ -74,6 +144,7 @@ export function TaskBoard() { - {tasks.map((task) => ( + {sortedTasks.map((task) => ( - + @@ -119,6 +126,41 @@ export function TodoDetailView() { {item.profile}
+ {item.children.length > 0 && ( +
+

+ Sub-tasks{' '} + + {item.completedChildCount}/{item.childCount} + +

+ {item.children.map((child) => ( +
{ + const state = location.state as { + activeProfile?: string; + scrollTop?: number; + } | null; + navigate(`/todos/${child.id}`, { + state: { + item: child, + activeProfile: state?.activeProfile, + scrollTop: state?.scrollTop, + }, + }); + }} + > + + {child.status === 'completed' ? '\u2713' : '\u25cb'} + + {child.summary} +
+ ))} +
+ )} + {item.sources.length > 0 && (

Sources

diff --git a/frontend/src/pages/TodoView.tsx b/frontend/src/pages/TodoView.tsx index 819c8d4b..8f185f30 100644 --- a/frontend/src/pages/TodoView.tsx +++ b/frontend/src/pages/TodoView.tsx @@ -1,5 +1,5 @@ -import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useMitzoStore } from '@mitzo/client/hooks'; import { TodoCard } from '../components/TodoCard'; import { EmptyState } from '../components/EmptyState'; @@ -8,6 +8,59 @@ import { useTodoData } from '../hooks/useTodoData'; import { buildPrompt, buildTodoContext } from '../lib/todo-utils'; import type { TodoItem } from '../types/todo'; +// ─── Section grouping ────────────────────────────────────────────────────── + +interface TodoSection { + key: string; + label: string; + items: TodoItem[]; + defaultCollapsed: boolean; +} + +export function groupIntoSections(items: TodoItem[]): TodoSection[] { + const focus: TodoItem[] = []; + const active: TodoItem[] = []; + const seen: TodoItem[] = []; + const done: TodoItem[] = []; + + for (const item of items) { + if (item.status === 'completed') { + done.push(item); + } else if (item.status === 'acknowledged') { + seen.push(item); + } else if (item.starred && item.urgency >= 0.5) { + focus.push(item); + } else { + active.push(item); + } + } + + // Sort within sections — intentional direction differences: + // Focus/Active: highest urgency first, tie-break by newest (lower ageDays) + // Seen: oldest first (longest-waiting items surface) + // Done: newest first (most recent completions on top for review) + const byUrgencyDesc = (a: TodoItem, b: TodoItem) => + b.urgency - a.urgency || a.ageDays - b.ageDays; + focus.sort(byUrgencyDesc); + active.sort(byUrgencyDesc); + seen.sort((a, b) => a.ageDays - b.ageDays); + done.sort((a, b) => b.ageDays - a.ageDays); + + const sections: TodoSection[] = []; + if (focus.length > 0) + sections.push({ key: 'focus', label: 'Focus', items: focus, defaultCollapsed: false }); + if (active.length > 0) + sections.push({ key: 'active', label: 'Active', items: active, defaultCollapsed: false }); + if (seen.length > 0) + sections.push({ key: 'seen', label: 'Seen', items: seen, defaultCollapsed: false }); + if (done.length > 0) + sections.push({ key: 'done', label: 'Done', items: done, defaultCollapsed: true }); + + return sections; +} + +// ─── Create form ─────────────────────────────────────────────────────────── + function TodoCreateForm({ parentId, profile, @@ -75,12 +128,63 @@ function TodoCreateForm({ ); } +// ─── Section header ──────────────────────────────────────────────────────── + +function SectionHeader({ + label, + count, + collapsed, + onToggle, +}: { + label: string; + count: number; + collapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +// ─── Main view ───────────────────────────────────────────────────────────── + export function TodoView() { const navigate = useNavigate(); - const [activeProfile, setActiveProfile] = useState(undefined); + const location = useLocation(); + const restoredProfile = (location.state as { activeProfile?: string } | null)?.activeProfile; + const [activeProfile, setActiveProfile] = useState(restoredProfile); const { loading, items, profiles, ack, done, star, create, refresh } = useTodoData(activeProfile); const [creating, setCreating] = useState<{ parentId?: string } | null>(null); const setPendingSession = useMitzoStore((s) => s.setPendingSession); + const scrollRef = useRef(null); + const [collapsedSections, setCollapsedSections] = useState>({ + done: true, + }); + + const sections = useMemo(() => groupIntoSections(items), [items]); + + // Restore scroll position when returning from detail view + useEffect(() => { + const saved = (location.state as { scrollTop?: number } | null)?.scrollTop; + if (saved && scrollRef.current) { + scrollRef.current.scrollTop = saved; + } + }, [location.state]); + + const saveScrollPosition = useCallback(() => { + return scrollRef.current?.scrollTop ?? 0; + }, []); + + function toggleSection(key: string) { + setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] })); + } function handleStartSession(item: TodoItem) { setPendingSession({ @@ -91,7 +195,9 @@ export function TodoView() { } function handleTap(item: TodoItem) { - navigate(`/todos/${item.id}`, { state: { item } }); + navigate(`/todos/${item.id}`, { + state: { item, activeProfile, scrollTop: saveScrollPosition() }, + }); } function handleAddChild(parentId: string) { @@ -100,7 +206,7 @@ export function TodoView() { return (
- + -
+
{profiles.length > 1 && (
); diff --git a/frontend/src/pages/__tests__/SessionList.test.tsx b/frontend/src/pages/__tests__/SessionList.test.tsx index bbeac8c0..0d030121 100644 --- a/frontend/src/pages/__tests__/SessionList.test.tsx +++ b/frontend/src/pages/__tests__/SessionList.test.tsx @@ -3,6 +3,14 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { createElement, act } from 'react'; import { createRoot } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; +vi.mock('../../lib/event-bus-singleton', () => ({ + eventBus: { + on: vi.fn(() => vi.fn()), + onConnectionChange: vi.fn(() => vi.fn()), + connected: false, + }, +})); + import { SessionList } from '../SessionList'; function mockFetchResponses(overrides: Record = {}) { diff --git a/frontend/src/pages/__tests__/TaskBoard.test.tsx b/frontend/src/pages/__tests__/TaskBoard.test.tsx index f4c80578..2ab455bc 100644 --- a/frontend/src/pages/__tests__/TaskBoard.test.tsx +++ b/frontend/src/pages/__tests__/TaskBoard.test.tsx @@ -60,6 +60,11 @@ vi.mock('../../hooks/useTaskBoard', () => ({ useTaskBoard: () => ({ loading: mockLoading, tasks: mockTasks, + sortedTasks: mockTasks, + displayMeta: new Map(), + totalTokenUsage: 0, + showAll: false, + setShowAll: vi.fn(), loopStatus: { state: 'idle', goalId: null, diff --git a/frontend/src/pages/__tests__/TodoDetailView.test.tsx b/frontend/src/pages/__tests__/TodoDetailView.test.tsx index 968b4fbc..21b7cae6 100644 --- a/frontend/src/pages/__tests__/TodoDetailView.test.tsx +++ b/frontend/src/pages/__tests__/TodoDetailView.test.tsx @@ -149,6 +149,25 @@ describe('TodoDetailView', () => { ).toBeTruthy(); }); + it('renders a back button that navigates to /todos with preserved state', () => { + mockLocation.mockReturnValue({ + state: { item: fullItem, activeProfile: 'centaur', scrollTop: 150 }, + }); + + const { container } = render( + + + , + ); + + const backBtn = container.querySelector('.page-header-back')!; + expect(backBtn).toBeTruthy(); + fireEvent.click(backBtn); + expect(mockNavigate).toHaveBeenCalledWith('/todos', { + state: { activeProfile: 'centaur', scrollTop: 150 }, + }); + }); + it('navigates to chat with prompt on "Open in Chat" click', () => { const { container } = render( @@ -192,6 +211,80 @@ describe('TodoDetailView', () => { openSpy.mockRestore(); }); + it('renders children as sub-tasks with progress count', () => { + const childItem: TodoItem = { + ...fullItem, + id: 'child1', + summary: 'Sub-task one', + status: 'active', + parentId: 'abc123', + children: [], + childCount: 0, + completedChildCount: 0, + }; + const doneChild: TodoItem = { + ...childItem, + id: 'child2', + summary: 'Sub-task two (done)', + status: 'completed', + }; + const parentWithChildren: TodoItem = { + ...fullItem, + children: [childItem, doneChild], + childCount: 2, + completedChildCount: 1, + }; + + mockLocation.mockReturnValue({ state: { item: parentWithChildren } }); + + const { container } = render( + + + , + ); + + expect(screen.getByText('Sub-tasks')).toBeTruthy(); + expect(container.querySelector('.todo-detail-children-count')?.textContent).toBe('1/2'); + expect(screen.getByText('Sub-task one')).toBeTruthy(); + expect(screen.getByText('Sub-task two (done)')).toBeTruthy(); + + const doneRow = screen.getByText('Sub-task two (done)').closest('.todo-detail-child-row'); + expect(doneRow?.className).toContain('todo-detail-child-row--done'); + }); + + it('navigates to child detail view when child row is tapped', () => { + const childItem: TodoItem = { + ...fullItem, + id: 'child1', + summary: 'Sub-task one', + parentId: 'abc123', + children: [], + childCount: 0, + completedChildCount: 0, + }; + const parentWithChildren: TodoItem = { + ...fullItem, + children: [childItem], + childCount: 1, + completedChildCount: 0, + }; + + mockLocation.mockReturnValue({ + state: { item: parentWithChildren, activeProfile: 'centaur', scrollTop: 50 }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByText('Sub-task one')); + expect(mockNavigate).toHaveBeenCalledWith('/todos/child1', { + state: { item: childItem, activeProfile: 'centaur', scrollTop: 50 }, + }); + }); + it('renders gracefully with empty context hints', () => { const emptyItem: TodoItem = { ...fullItem, diff --git a/frontend/src/pages/__tests__/TodoView.test.tsx b/frontend/src/pages/__tests__/TodoView.test.tsx index efbf557a..07f904e5 100644 --- a/frontend/src/pages/__tests__/TodoView.test.tsx +++ b/frontend/src/pages/__tests__/TodoView.test.tsx @@ -186,7 +186,9 @@ describe('TodoView', () => { fireEvent.touchStart(card, { touches: [{ clientX: 100, clientY: 200 }] }); fireEvent.touchEnd(card); - expect(mockNavigate).toHaveBeenCalledWith('/todos/abc', { state: { item } }); + expect(mockNavigate).toHaveBeenCalledWith('/todos/abc', { + state: { item, activeProfile: undefined, scrollTop: 0 }, + }); }); it('renders MitzoLogo for home navigation', () => { diff --git a/frontend/src/styles/desktop.css b/frontend/src/styles/desktop.css index 64f2e9cc..cdc716c3 100644 --- a/frontend/src/styles/desktop.css +++ b/frontend/src/styles/desktop.css @@ -169,6 +169,60 @@ font-weight: 600; } + /* === Desktop Nav === */ + .desktop-nav { + display: flex; + flex-direction: column; + gap: 1px; + padding: var(--space-2); + padding-bottom: var(--space-2); + margin-bottom: var(--space-1); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + .desktop-nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1h) var(--space-2); + border-radius: 6px; + font-size: var(--text-s); + color: var(--text-dim); + background: none; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + } + + .desktop-nav-item:hover { + background: var(--border); + color: var(--text); + } + + .desktop-nav-item--active { + color: var(--accent); + background: rgba(108, 99, 255, 0.1); + } + + .desktop-nav-item--active:hover { + background: rgba(108, 99, 255, 0.15); + } + + .desktop-nav-badge { + background: var(--danger); + color: #fff; + font-size: var(--text-2xs); + min-width: 16px; + height: 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + } + /* === Session Panel === */ .session-panel { display: flex; @@ -462,6 +516,506 @@ min-height: 0; } + /* === Session View Toggle === */ + .session-view-toggle { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + } + + .session-view-toggle button { + flex: 1; + padding: var(--space-1) 0; + font-size: var(--text-xs); + background: transparent; + border: none; + cursor: pointer; + color: var(--text-dim); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + } + + .session-view-toggle button.active { + background: var(--accent); + color: #fff; + } + + .session-view-badge { + background: var(--danger); + color: #fff; + font-size: 0.5625rem; + min-width: 14px; + height: 14px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + } + + /* === Session Status Dots === */ + .session-panel-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .session-panel-dot--attached { + background: var(--success); + box-shadow: 0 0 4px var(--success); + } + + .session-panel-dot--detached { + background: var(--warning, #f59e0b); + } + + /* === Session Close Status === */ + .session-panel-status { + flex-shrink: 0; + font-size: 10px; + line-height: 1; + width: 14px; + text-align: center; + } + + .session-panel-status--user { + color: var(--success, #22c55e); + } + + .session-panel-status--auto { + color: var(--text-muted, #6b7280); + } + + .session-panel-status--abandoned { + color: var(--error, #ef4444); + opacity: 0.6; + } + + /* === Session Close Button === */ + .session-close-btn { + background: none; + border: 1px solid var(--border, #374151); + border-radius: 4px; + color: var(--text-muted, #6b7280); + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + line-height: 1; + transition: color 0.15s, border-color 0.15s; + } + + .session-close-btn:hover { + color: var(--error, #ef4444); + border-color: var(--error, #ef4444); + } + + /* === Active Sessions List === */ + .active-sessions-list { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1; + min-height: 0; + } + + .active-sessions-summary { + font-size: var(--text-xxs); + color: #ff6d6d; + font-weight: 600; + padding: 0 var(--space-1); + margin-bottom: var(--space-1); + } + + /* === Command Center === */ + .command-center { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + } + + /* === Collapsible Section === */ + .cc-section { + border-bottom: 1px solid var(--border); + } + + .cc-section-header { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1h) var(--space-2); + cursor: pointer; + user-select: none; + width: 100%; + background: none; + border: none; + color: var(--text-dim); + text-align: left; + } + + .cc-section-header:hover { + background: var(--border); + } + + .cc-section-title { + font-size: var(--text-xxs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + flex: 1; + } + + .cc-section-badge { + background: var(--danger); + color: #fff; + border-radius: 8px; + padding: 0 6px; + font-size: 0.5625rem; + font-weight: 700; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + } + + .cc-section-actions { + display: flex; + gap: 2px; + } + + .cc-section-action-btn { + padding: 0 var(--space-1); + font-size: var(--text-xs); + color: var(--text-dim); + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + } + + .cc-section-action-btn:hover { + color: var(--text); + background: var(--border); + } + + .cc-section-chevron { + font-size: var(--text-xs); + transition: transform 0.15s; + transform: rotate(0deg); + } + + .cc-section-chevron--open { + transform: rotate(90deg); + } + + .cc-section-body { + padding: 0 var(--space-1h) var(--space-1h); + } + + /* === Command Center Cards === */ + .cc-card { + display: flex; + align-items: flex-start; + gap: var(--space-1h); + padding: var(--space-1h) var(--space-1h); + border-left: 3px solid transparent; + border-radius: 4px; + cursor: pointer; + margin-bottom: 2px; + } + + .cc-card:hover { + background: var(--border); + } + + .cc-card--current { + background: var(--border); + } + + .cc-card--waiting { + border-left-color: #ff6d6d; + } + + .cc-card--working { + border-left-color: #b48cff; + } + + .cc-card--done { + border-left-color: #4ade80; + } + + .cc-card--urgency-high { + border-left-color: #fbbf24; + } + + .cc-card--urgency-med { + border-left-color: #b48cff; + } + + .cc-card-icon { + font-size: var(--text-s); + flex-shrink: 0; + width: 1rem; + text-align: center; + } + + .cc-card-content { + flex: 1; + min-width: 0; + } + + .cc-card-title { + font-size: var(--text-s); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; + } + + .cc-card-repo { + color: var(--text-dim); + } + + .cc-card-agent { + color: var(--accent); + font-weight: 500; + } + + .cc-card-meta { + font-size: var(--text-xxs); + color: var(--text-dim); + display: flex; + gap: var(--space-1); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .cc-card-actions { + display: flex; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s; + } + + .cc-card:hover .cc-card-actions { + opacity: 1; + } + + /* === Command Center Buttons === */ + .cc-btn { + padding: 2px 6px; + font-size: var(--text-xxs); + border-radius: 4px; + border: none; + cursor: pointer; + background: var(--border); + color: var(--text-dim); + } + + .cc-btn:hover { + color: var(--text); + } + + .cc-btn--approve { + color: #4ade80; + } + + .cc-btn--approve:hover { + background: rgba(74, 222, 128, 0.15); + color: #4ade80; + } + + .cc-btn--danger { + color: #ff6d6d; + } + + .cc-btn--danger:hover { + background: rgba(255, 109, 109, 0.15); + color: #ff6d6d; + } + + .cc-btn--subtle { + background: none; + } + + /* === Command Center Empty / Misc === */ + .cc-empty { + font-size: var(--text-xs); + color: var(--text-dim); + text-align: center; + padding: var(--space-2) 0; + } + + /* === Filter Pills === */ + .cc-filter-pills { + display: flex; + gap: var(--space-1); + margin-bottom: var(--space-1h); + flex-wrap: wrap; + } + + .cc-filter-pill { + font-size: var(--text-xxs); + padding: 1px 8px; + border-radius: 4px; + background: var(--border); + color: var(--text-dim); + border: none; + cursor: pointer; + } + + .cc-filter-pill:hover { + color: var(--text); + } + + .cc-filter-pill--active { + background: var(--accent); + color: #fff; + } + + /* === Tier Headers === */ + .cc-tier { + margin-bottom: var(--space-1); + } + + .cc-tier-header { + font-size: 0.5625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + padding: var(--space-1) var(--space-1h); + margin-top: var(--space-1); + } + + .cc-tier-header--dim { + opacity: 0.6; + } + + /* === Inline Create Form === */ + .cc-inline-create { + display: flex; + gap: var(--space-1); + padding: var(--space-1) 0; + } + + .cc-inline-input { + flex: 1; + padding: var(--space-1) var(--space-1h); + font-size: var(--text-xs); + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + outline: none; + } + + .cc-inline-input:focus { + border-color: var(--accent); + } + + /* === Loop Bar (compact) === */ + .cc-loop-bar { + padding: var(--space-1h); + margin-bottom: var(--space-1); + background: var(--bg); + border-radius: 4px; + } + + .cc-loop-status { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + } + + .cc-loop-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + } + + .cc-loop-label { + font-weight: 500; + } + + .cc-loop-progress { + color: var(--text-dim); + font-size: var(--text-xxs); + margin-left: auto; + } + + .cc-loop-actions { + display: flex; + gap: var(--space-1); + margin-top: var(--space-1); + } + + .cc-loop-progress-bar { + height: 3px; + background: var(--border); + border-radius: 2px; + margin-top: var(--space-1); + overflow: hidden; + } + + .cc-loop-progress-fill { + height: 100%; + background: #b48cff; + border-radius: 2px; + transition: width 0.3s ease; + } + + /* === Approval Gate === */ + .cc-approval { + padding: var(--space-1h); + margin-bottom: var(--space-1); + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 4px; + } + + .cc-approval-msg { + font-size: var(--text-xs); + color: #fbbf24; + margin-bottom: var(--space-1); + } + + .cc-approval-actions { + display: flex; + gap: var(--space-1); + } + + /* === Task list in sidebar === */ + .cc-task-list { + font-size: var(--text-s); + } + + .cc-task-list .task-node-row { + padding: var(--space-1) 0; + } + + .cc-task-list .task-node-actions { + opacity: 0; + transition: opacity 0.1s; + } + + .cc-task-list .task-node-row:hover .task-node-actions { + opacity: 1; + } + /* === Calendar desktop layout === */ .cal-body { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 364d2806..44293488 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -14,6 +14,7 @@ --accent-hover: #5a52e0; --danger: #f44336; --success: #4caf50; + --success-rgb: 76, 175, 80; --code-bg: #0e0e10; --code-font: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace; @@ -22,8 +23,19 @@ --hover: rgba(108, 99, 255, 0.1); --active: rgba(108, 99, 255, 0.2); --warning: #ff9800; + --status-ok: #4ade80; + --status-warn: #fbbf24; --shadow: rgba(0, 0, 0, 0.3); + /* Task state colors */ + --task-pending: var(--text-dim); + --task-active: #b48cff; + --task-done: #4ade80; + --task-review: #fbbf24; + --task-blocked: #ff6d6d; + --task-failed: #ef4444; + --task-skipped: var(--text-dim); + /* Type scale */ --text-2xs: 0.65rem; --text-xxs: 0.7rem; @@ -319,6 +331,10 @@ textarea:focus { background: var(--hover); } +.tab-bar-more-item--danger { + color: #ef4444; +} + /* ── EmptyState ─────────────────────────────────────────── */ .empty-state { @@ -624,6 +640,220 @@ textarea:focus { color: #fff; } +/* --- Session Overview --- */ + +.overview-section { + margin-bottom: var(--space-4); +} + +.overview-header { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) 0; + background: transparent; + border: none; + color: var(--text-dim); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.overview-header-title { + flex-shrink: 0; +} + +.overview-header-summary { + flex: 1; + text-align: right; + font-weight: 400; + font-size: var(--text-xs); + color: var(--text-dim); + opacity: 0.7; +} + +.overview-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff6d6d; + color: #fff; + font-size: var(--text-2xs); + font-weight: 700; + flex-shrink: 0; +} + +.overview-chevron { + display: inline-block; + font-size: var(--text-base); + transition: transform 0.2s; + transform: rotate(0deg); + flex-shrink: 0; +} + +.overview-chevron--open { + transform: rotate(90deg); +} + +.overview-cards { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); +} + +.overview-card { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--card-accent, var(--border)); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--text); + font-family: inherit; + font-size: var(--text-sm); + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} + +.overview-card:active { + background: var(--hover); +} + +.overview-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; +} + +.overview-card-content { + flex: 1; + min-width: 0; +} + +.overview-card-title { + font-size: var(--text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overview-card-repo { + color: var(--text-dim); + font-weight: 600; +} + +.overview-card-meta { + font-size: var(--text-xs); + color: var(--text-dim); + margin-top: 2px; +} + +/* ─── Attention Feed ────────────────────────────────────────────────────── */ + +.attention-section { + margin-bottom: var(--space-4); +} + +.attention-cards { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); +} + +.attention-card { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--card-accent, var(--border)); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--text); + font-family: inherit; + font-size: var(--text-sm); + width: 100%; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} + +.attention-card:active { + background: var(--hover); +} + +.attention-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; +} + +.attention-card-content { + flex: 1; + min-width: 0; +} + +.attention-card-title { + font-size: var(--text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.attention-card-meta { + font-size: var(--text-xs); + color: var(--text-dim); + margin-top: 2px; +} + +.attention-card-source { + font-weight: 600; + text-transform: uppercase; + font-size: var(--text-2xs); + letter-spacing: 0.03em; +} + +.attention-empty { + font-size: var(--text-xs); + color: var(--text-dim); + padding: var(--space-3) 0; + text-align: center; +} + +.service-status { + display: flex; + gap: var(--space-4); + padding: var(--space-2) 0; + font-size: var(--text-xs); + color: var(--text-dim); +} + +.service-dot { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.service-dot-indicator { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; +} + .quick-section { margin-bottom: var(--space-5); } @@ -1387,6 +1617,24 @@ textarea:focus { } } +/* Session close button (mobile) */ +.session-close-btn { + background: none; + border: 1px solid var(--border, #374151); + border-radius: 4px; + color: var(--text-muted, #6b7280); + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + line-height: 1; + flex-shrink: 0; +} + +.session-close-btn:hover { + color: var(--error, #ef4444); + border-color: var(--error, #ef4444); +} + .chat-empty { text-align: center; color: var(--text-dim); @@ -2143,93 +2391,378 @@ textarea:focus { opacity: 0.7; } -/* ===== Tool Group ===== */ +/* ===== Boot Context Pill (ContexGin metadata) ===== */ -.tool-group { +.boot-context-pill { align-self: flex-start; width: 100%; border-radius: 6px; overflow: hidden; + margin-bottom: 0.3rem; } -.tool-group-header { +.boot-context-pill-header { display: flex; align-items: center; - gap: var(--space-2); + gap: 0.4rem; touch-action: manipulation; - padding: 0.4rem 0.6rem; + padding: 0.25rem 0.6rem; width: 100%; cursor: pointer; background: transparent; border: none; -webkit-tap-highlight-color: transparent; - touch-action: manipulation; - color: var(--text-dim); - font-size: var(--text-xs); + font-size: var(--text-xxs); font-family: inherit; + color: var(--text-dim); + min-width: 0; } -.tool-group-header:active { +.boot-context-pill-header:active { background: rgba(255, 255, 255, 0.03); } -.tool-group-dots { - display: flex; - gap: 3px; +.boot-context-pill-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; flex-shrink: 0; } -.tool-group-label { +.boot-context-pill-dot--ok { + background: var(--status-ok); +} + +.boot-context-pill-dot--warn { + background: var(--status-warn); +} + +.boot-context-pill-label { font-size: var(--text-xxs); - color: var(--text-dim); } -.tool-group-chevron { +.boot-context-pill-engine { + font-size: var(--text-xxs); + opacity: 0.5; +} + +.boot-context-pill-chevron { margin-left: auto; font-size: 0.6rem; + opacity: 0.5; +} + +.boot-context-pill-content { + padding: 0.3rem 0.6rem 0.3rem 1.2rem; + font-family: var(--code-font); + font-size: var(--text-xxs); + line-height: 1.5; color: var(--text-dim); + opacity: 0.7; } -.tool-group-list { - display: flex; - flex-direction: column; - gap: 1px; - padding: 0 0 var(--space-1); - background: var(--surface); - border-radius: 0 0 6px 6px; - max-height: 40vh; - overflow-y: auto; - -webkit-overflow-scrolling: touch; +.boot-context-pill-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tool-group-dots-more { - font-size: 0.6rem; - color: var(--text-dim); - line-height: 6px; - flex-shrink: 0; +.boot-context-pill-trimmed { + margin-top: 0.2rem; + font-style: italic; + color: var(--status-warn); } -/* ===== Progress Widget ===== */ +/* ===== Markdown Preview Card ===== */ -.progress-widget { +.md-preview-card { align-self: flex-start; width: 100%; border-radius: 6px; overflow: hidden; + border-left: 3px solid #7dd3fc; + margin: 0.4em 0; } -.progress-widget-header { +.md-preview-card-header { + display: flex; + align-items: center; + min-width: 0; +} + +.md-preview-card-toggle { display: flex; align-items: center; gap: 0.4rem; - padding: 0.45rem 0.6rem; - width: 100%; + flex: 1; + min-width: 0; + touch-action: manipulation; + padding: 0.35rem 0.6rem; cursor: pointer; background: transparent; border: none; -webkit-tap-highlight-color: transparent; - touch-action: manipulation; - color: var(--text-dim); + color: var(--text); + font-size: var(--text-xs); + font-family: inherit; +} + +.md-preview-card-toggle:active { + background: rgba(125, 211, 252, 0.05); +} + +.md-preview-card-icon { + font-size: var(--text-xxs); + font-weight: 700; + color: #7dd3fc; + background: rgba(125, 211, 252, 0.12); + padding: 1px 4px; + border-radius: 3px; + flex-shrink: 0; + font-family: var(--code-font); +} + +.md-preview-card-name { + font-weight: 500; + color: var(--text); + font-family: var(--code-font); + font-size: var(--text-xxs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.md-preview-card-open { + font-size: var(--text-xxs); + color: var(--accent); + flex-shrink: 0; + padding: 2px 6px; + margin-right: 0.4rem; + border-radius: 3px; + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; + -webkit-tap-highlight-color: transparent; +} + +.md-preview-card-open:active { + background: rgba(99, 102, 241, 0.1); +} + +.md-preview-card-chevron { + color: var(--text-dim); + font-size: 0.6rem; + flex-shrink: 0; +} + +.md-preview-card-content { + border-top: 1px solid var(--border); + background: var(--code-bg); + border-radius: 0 0 6px 6px; +} + +.md-preview-card-status { + padding: 0.4rem 0.6rem; + font-size: var(--text-xxs); + color: var(--text-dim); + margin: 0; +} + +.md-preview-card-status--error { + color: #f8a0a0; +} + +.md-preview-card-body { + padding: 0.5rem 0.6rem; + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + font-size: var(--text-xs); + line-height: 1.5; + color: var(--text); +} + +.md-preview-card-body h1, +.md-preview-card-body h2, +.md-preview-card-body h3, +.md-preview-card-body h4 { + font-weight: 600; + margin: 0.6em 0 0.3em; + color: #fff; +} +.md-preview-card-body h1 { + font-size: 1.1em; +} +.md-preview-card-body h2 { + font-size: 1.05em; +} +.md-preview-card-body h3 { + font-size: 1em; +} +.md-preview-card-body h1:first-child, +.md-preview-card-body h2:first-child, +.md-preview-card-body h3:first-child { + margin-top: 0; +} +.md-preview-card-body p { + margin-bottom: 0.4em; +} +.md-preview-card-body p:last-child { + margin-bottom: 0; +} +.md-preview-card-body ul, +.md-preview-card-body ol { + padding-left: 1.2em; + margin-bottom: 0.4em; +} +.md-preview-card-body li { + margin-bottom: 0.15em; +} +.md-preview-card-body code { + font-family: var(--code-font); + font-size: 0.82em; + background: rgba(255, 255, 255, 0.06); + padding: 0.12em 0.3em; + border-radius: 3px; +} +.md-preview-card-body pre { + margin: 0.4em 0; + padding: 0.4rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 6px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.md-preview-card-body pre code { + background: transparent; + padding: 0; +} +.md-preview-card-body blockquote { + border-left: 2px solid var(--accent); + padding-left: 0.6em; + margin: 0.3em 0; + color: var(--text-dim); +} +.md-preview-card-body strong { + font-weight: 600; + color: #fff; +} +.md-preview-card-body a { + color: var(--accent); + text-decoration: underline; +} +.md-preview-card-body table { + border-collapse: collapse; + width: 100%; + font-size: 0.85em; + margin: 0.3em 0; +} +.md-preview-card-body th, +.md-preview-card-body td { + border: 1px solid var(--border); + padding: 0.25em 0.5em; +} +.md-preview-card-body th { + background: rgba(255, 255, 255, 0.04); + font-weight: 600; +} +.md-preview-card-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 0.5em 0; +} +/* ===== Tool Group ===== */ + +.tool-group { + align-self: flex-start; + width: 100%; + border-radius: 6px; + overflow: hidden; +} + +.tool-group-header { + display: flex; + align-items: center; + gap: var(--space-2); + touch-action: manipulation; + padding: 0.4rem 0.6rem; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + color: var(--text-dim); + font-size: var(--text-xs); + font-family: inherit; +} + +.tool-group-header:active { + background: rgba(255, 255, 255, 0.03); +} + +.tool-group-dots { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.tool-group-label { + font-size: var(--text-xxs); + color: var(--text-dim); +} + +.tool-group-chevron { + margin-left: auto; + font-size: 0.6rem; + color: var(--text-dim); +} + +.tool-group-list { + display: flex; + flex-direction: column; + gap: 1px; + padding: 0 0 var(--space-1); + background: var(--surface); + border-radius: 0 0 6px 6px; + max-height: 40vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.tool-group-dots-more { + font-size: 0.6rem; + color: var(--text-dim); + line-height: 6px; + flex-shrink: 0; +} + +/* ===== Progress Widget ===== */ + +.progress-widget { + align-self: flex-start; + width: 100%; + border-radius: 6px; + overflow: hidden; +} + +.progress-widget-header { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + color: var(--text-dim); font-size: var(--text-xs); font-family: inherit; min-width: 0; @@ -3887,24 +4420,42 @@ textarea:focus { padding: 12px 14px; cursor: pointer; touch-action: pan-y; + display: flex; + flex-direction: column; + gap: 4px; } -.todo-card-header { +/* ─── 3-line card layout ──────────────────────────────────────────── */ + +.todo-card-line1 { display: flex; - align-items: center; + align-items: flex-start; gap: var(--space-2); - margin-bottom: var(--space-1); - font-size: var(--text-xs); } -.todo-card-status { - color: var(--accent); +.todo-card-icon { + flex-shrink: 0; + font-size: var(--text-base); + line-height: 1.3; } -.todo-card-urgency { - font-family: monospace; - letter-spacing: 1px; +.todo-card-summary { + flex: 1; + font-size: var(--text-sm); + line-height: 1.35; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.todo-card-line2 { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); color: var(--text-dim); + padding-left: calc(var(--text-base) + var(--space-2)); } .todo-card-source { @@ -3918,17 +4469,9 @@ textarea:focus { .todo-card-age { color: var(--text-dim); - margin-left: auto; -} - -.todo-card-summary { - font-size: var(--text-sm); - line-height: 1.35; } -.todo-card-meta { - margin-top: var(--space-1); - font-size: var(--text-xxs); +.todo-card-profile { color: var(--text-dim); } @@ -3936,19 +4479,77 @@ textarea:focus { color: var(--text-dim); } -/* --- Hierarchical todo tree --- */ +.todo-card-line3 { + display: flex; + gap: var(--space-2); + padding-left: calc(var(--text-base) + var(--space-2)); + margin-top: 2px; +} -.todo-card-tree-node--child { - margin-left: 20px; - border-left: 2px solid var(--border); - padding-left: var(--space-2); +/* ─── Section headers ─────────────────────────────────────────────── */ + +.todo-section { + margin-bottom: var(--space-3); } -.todo-card-expand { - background: none; - border: none; - color: var(--text-dim); - font-size: var(--text-2xs); +.todo-section-header { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) 0; + background: transparent; + border: none; + color: var(--text-dim); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.todo-section-label { + flex-shrink: 0; +} + +.todo-section-count { + flex-shrink: 0; + font-weight: 400; + opacity: 0.7; +} + +.todo-section-line { + flex: 1; + height: 1px; + background: var(--border); +} + +.todo-section-chevron { + display: inline-block; + font-size: var(--text-base); + transition: transform 0.2s; + transform: rotate(0deg); + flex-shrink: 0; +} + +.todo-section-chevron--open { + transform: rotate(90deg); +} + +/* --- Hierarchical todo tree --- */ + +.todo-card-tree-node--child { + margin-left: 20px; + border-left: 2px solid var(--border); + padding-left: var(--space-2); +} + +.todo-card-expand { + background: none; + border: none; + color: var(--text-dim); + font-size: var(--text-2xs); padding: 2px 6px; cursor: pointer; flex-shrink: 0; @@ -3982,7 +4583,6 @@ textarea:focus { padding: 2px 8px; border-radius: 10px; cursor: pointer; - margin-top: var(--space-1); opacity: 0.4; transition: opacity 0.15s; } @@ -3993,12 +4593,6 @@ textarea:focus { opacity: 1; } -.todo-card-actions { - display: flex; - gap: var(--space-2); - margin-top: var(--space-1); -} - .todo-card-session-btn { background: none; border: 1px solid var(--accent, #6366f1); @@ -4215,6 +4809,47 @@ textarea:focus { margin-left: auto; } +/* --- Sub-tasks --- */ + +.todo-detail-children { + padding: var(--space-2) 0; +} + +.todo-detail-children-count { + font-weight: 400; + color: var(--accent); +} + +.todo-detail-child-row { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 10px 12px; + background: var(--surface); + border-radius: 8px; + margin-bottom: var(--space-1h); + cursor: pointer; + transition: background 0.15s; +} + +.todo-detail-child-row:active { + background: var(--border); +} + +.todo-detail-child-row--done { + opacity: 0.5; +} + +.todo-detail-child-status { + flex-shrink: 0; + font-size: var(--text-s); +} + +.todo-detail-child-summary { + font-size: var(--text-s); + line-height: 1.4; +} + /* --- Sources --- */ .todo-detail-sources { @@ -4222,6 +4857,7 @@ textarea:focus { } .todo-detail-sources h2, +.todo-detail-children h2, .todo-detail-task-hint h2, .todo-detail-context h2 { font-size: var(--text-s); @@ -4453,29 +5089,65 @@ textarea:focus { .task-node { padding: var(--space-1) 0; + transition: opacity 0.5s ease; } -.task-node--active > .task-node-row { - border-left: 3px solid var(--accent); -} - -.task-node-action--approve:hover { - color: var(--success); +.task-node--hidden { + display: none; } .task-node-row { display: flex; - align-items: center; + align-items: flex-start; gap: var(--space-1h); - padding: var(--space-1h) var(--space-2); + padding: var(--space-2) var(--space-2); border-radius: 6px; background: var(--surface); + border-left: 3px solid transparent; } .task-node-row:hover { background: var(--border); } +/* State-colored left borders */ +.task-node--status-pending > .task-node-row { + border-left-color: var(--task-pending); +} +.task-node--status-active > .task-node-row { + border-left-color: var(--task-active); +} +.task-node--status-done > .task-node-row { + border-left-color: var(--task-done); +} +.task-node--status-pending_review > .task-node-row { + border-left-color: var(--task-review); +} +.task-node--status-blocked > .task-node-row { + border-left-color: var(--task-blocked); +} +.task-node--status-skipped > .task-node-row { + border-left-color: var(--task-skipped); +} +.task-node--status-failed > .task-node-row { + border-left-color: var(--task-failed); +} + +/* T1 (needs you) background tint */ +.task-node--t1.task-node--status-pending_review > .task-node-row { + background: rgba(251, 191, 36, 0.08); +} +.task-node--t1.task-node--status-blocked > .task-node-row { + background: rgba(255, 109, 109, 0.08); +} +.task-node--t1.task-node--status-failed > .task-node-row { + background: rgba(239, 68, 68, 0.08); +} + +.task-node-action--approve:hover { + color: var(--success); +} + .task-node-chevron { background: none; border: none; @@ -4484,6 +5156,7 @@ textarea:focus { cursor: pointer; padding: 2px 4px; min-width: 20px; + margin-top: 2px; } .task-node-status { @@ -4494,40 +5167,45 @@ textarea:focus { padding: 2px; min-width: 20px; text-align: center; + margin-top: 1px; } .task-node-status--pending { - color: var(--text-dim); + color: var(--task-pending); } - .task-node-status--active { - color: var(--accent); + color: var(--task-active); } - .task-node-status--done { - color: var(--success); + color: var(--task-done); } - .task-node-status--pending_review { - color: var(--accent); + color: var(--task-review); } - .task-node-status--blocked { - color: var(--warning); + color: var(--task-blocked); } - .task-node-status--skipped { - color: var(--text-dim); + color: var(--task-skipped); } - .task-node-status--failed { - color: var(--danger); + color: var(--task-failed); } -.task-node-title { +.task-node-body { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.task-node-title { font-size: var(--text-md); line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .task-node-title--done { @@ -4535,11 +5213,35 @@ textarea:focus { color: var(--text-dim); } +.task-node-context { + font-size: var(--text-xs); + color: var(--text-dim); + margin-top: 2px; + line-height: 1.3; +} + +.task-node-context--review { + color: var(--task-review); +} +.task-node-context--blocked { + color: var(--task-blocked); +} +.task-node-context--failed { + color: var(--task-failed); +} + +.task-node-meta { + font-size: var(--text-xs); + color: var(--text-dim); + line-height: 1.3; +} + .task-node-actions { display: flex; gap: var(--space-1); opacity: 0; transition: opacity 0.15s; + align-self: center; } .task-node-row:hover .task-node-actions { @@ -4570,6 +5272,21 @@ textarea:focus { border-left: 1px solid var(--border); } +/* Show all toggle */ +.task-board-show-all { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: var(--text-xs); + padding: 2px 8px; + border-radius: 10px; + cursor: pointer; +} +.task-board-show-all--active { + border-color: var(--accent); + color: var(--accent); +} + /* ── Task Create Form ───────────────────────────────────── */ .task-create-form { @@ -4640,50 +5357,38 @@ textarea:focus { /* ── Loop Controls ────────────────────────────────────── */ .loop-controls { - padding: var(--space-2) 0; - margin-bottom: var(--space-2); + padding: var(--space-1h) 0; + margin-bottom: var(--space-1h); } -.loop-controls-header { +/* Idle: compact trigger */ +.loop-controls-start-trigger { display: flex; align-items: center; gap: var(--space-2); - margin-bottom: var(--space-1h); -} - -.loop-controls-pill { - font-size: var(--text-xs); - font-weight: 600; - padding: 2px 10px; - border-radius: 10px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.loop-controls-pill--idle { - background: var(--border); + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: var(--space-2) var(--space-3); color: var(--text-dim); + font-size: var(--text-sm); + cursor: pointer; } - -.loop-controls-pill--running { - background: var(--success); - color: var(--bg); -} - -.loop-controls-pill--paused { - background: var(--warning); - color: var(--bg); -} - -.loop-controls-progress { - font-size: var(--text-s); - color: var(--text-dim); +.loop-controls-start-trigger:hover { + background: var(--border); + color: var(--text); } -.loop-controls-start { +/* Expanded picker (shown when trigger is tapped) */ +.loop-controls-start-expanded { display: flex; flex-direction: column; gap: var(--space-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: var(--space-3); } .loop-controls-start-row { @@ -4695,7 +5400,7 @@ textarea:focus { .loop-controls-select { flex: 1; min-width: 120px; - background: var(--surface); + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; @@ -4712,16 +5417,74 @@ textarea:focus { cursor: pointer; } -.loop-controls-actions { +/* Inline bar (running/paused) */ +.loop-controls-inline-bar { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.loop-controls-inline-bar-top { display: flex; + align-items: center; gap: var(--space-2); } +.loop-controls-inline-bar-bottom { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.loop-controls-pill { + font-size: var(--text-xs); + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.loop-controls-pill--running { + background: var(--task-active); + color: var(--bg); +} + +.loop-controls-pill--paused { + background: var(--warning); + color: var(--bg); +} + +.loop-controls-pill--review { + background: var(--task-review); + color: var(--bg); +} + +.loop-controls-progress { + font-size: var(--text-s); + color: var(--text-dim); +} + +.loop-controls-token-count { + font-size: var(--text-xs); + color: var(--text-dim); + margin-left: auto; +} + +.loop-controls-spacer { + flex: 1; +} + +.loop-controls-actions { + display: flex; + gap: var(--space-1); +} + .loop-controls-btn { - background: var(--surface); + background: none; border: 1px solid var(--border); border-radius: 6px; - padding: 6px 14px; + padding: 4px 10px; font-size: var(--text-sm); color: var(--text); cursor: pointer; @@ -4747,36 +5510,40 @@ textarea:focus { } .loop-controls-btn--danger { - background: var(--danger); - color: white; + color: var(--danger); border-color: var(--danger); } .loop-controls-btn--danger:hover { - opacity: 0.9; + background: rgba(244, 67, 54, 0.1); } -.loop-controls-approval { +/* Approval card */ +.loop-controls-approval-card { margin-top: var(--space-2); + padding: var(--space-3); + background: rgba(251, 191, 36, 0.08); + border: 1px solid var(--task-review); + border-radius: 8px; } .loop-controls-approval-msg { font-size: var(--text-sm); - color: var(--accent); - margin-bottom: var(--space-1h); + color: var(--task-review); + margin-bottom: var(--space-2); } .loop-controls-bar { height: 4px; background: var(--border); border-radius: 2px; - margin-top: var(--space-2); overflow: hidden; + flex: 1; } .loop-controls-bar-fill { height: 100%; - background: var(--success); + background: var(--task-active); border-radius: 2px; transition: width 0.3s ease; } @@ -5055,3 +5822,87 @@ textarea:focus { .isolation-toggle:hover { opacity: 0.8; } + +/* ━━━ Subagent Card ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.subagent-card { + margin: 8px 0 0 0; + padding-left: 12px; + border-left: 2px solid var(--success); + background: rgba(var(--success-rgb), 0.05); + border-radius: 4px; + overflow: hidden; +} + +.subagent-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 8px 0; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + font-family: inherit; + font-size: var(--text-sm); + color: var(--text); +} + +.subagent-header:active { + background: rgba(255, 255, 255, 0.03); +} + +.subagent-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.subagent-dot--running { + background: var(--success); + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.subagent-dot--done { + background: var(--success); +} + +.subagent-summary { + flex: 1; + text-align: left; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subagent-tokens { + font-size: var(--text-xs); + color: var(--text-dim); + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex-shrink: 0; +} + +.subagent-chevron { + color: var(--text-dim); + font-size: var(--text-xs); + flex-shrink: 0; +} + +.subagent-detail { + padding: 0 12px 8px 0; +} + +.subagent-text { + padding: 8px 0; + color: var(--text-dim); + font-size: var(--text-sm); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/frontend/src/types/ws-messages.ts b/frontend/src/types/ws-messages.ts index c143df68..a16d8fe1 100644 --- a/frontend/src/types/ws-messages.ts +++ b/frontend/src/types/ws-messages.ts @@ -233,7 +233,14 @@ export type ServerMessage = | LoopStatusMsg | ProgressStartMsg | ProgressUpdateMsg - | ProgressReplaceMsg; + | ProgressReplaceMsg + | SubagentStartMsg + | SubagentBlockStartMsg + | SubagentBlockDeltaMsg + | SubagentBlockEndMsg + | SubagentToolResultMsg + | SubagentEndMsg + | SubagentCancelledMsg; export interface ProgressStartMsg { type: 'progress_start'; @@ -283,3 +290,92 @@ export interface LoopStatusMsg { specMode: boolean; awaitingApproval: boolean; } + +// Subagent lifecycle events +export interface SubagentStartMsg { + type: 'subagent_start'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + parentToolId: string; + subagentMessageId: string; + description?: string; +} + +export interface SubagentBlockStartMsg { + type: 'subagent_block_start'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + blockId: string; + blockType: BlockType; + toolName?: string; +} + +export interface SubagentBlockDeltaMsg { + type: 'subagent_block_delta'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + blockId: string; + blockType: BlockType; + delta: string; +} + +export interface SubagentBlockEndMsg { + type: 'subagent_block_end'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + blockId: string; + blockType: BlockType; + toolName?: string; + toolId?: string; + input?: string; + rawInput?: RawToolInput; +} + +export interface SubagentToolResultMsg { + type: 'subagent_tool_result'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + toolId: string; + result: string; + isError: boolean; +} + +export interface SubagentEndMsg { + type: 'subagent_end'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + summary?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }; +} + +export interface SubagentCancelledMsg { + type: 'subagent_cancelled'; + v: 2; + ts: number; + sessionId: string; + parentBlockId: string; + subagentMessageId: string; + taskId: string; +} diff --git a/packages/client/__tests__/event-bus.test.ts b/packages/client/__tests__/event-bus.test.ts new file mode 100644 index 00000000..3e7cf2cf --- /dev/null +++ b/packages/client/__tests__/event-bus.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventBus } from '../src/event-bus.js'; + +// ─── Mock EventSource ─────────────────────────────────────────────────────── + +class MockEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + + readyState = MockEventSource.CONNECTING; + onopen: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + private handlers = new Map void)[]>(); + + close = vi.fn(() => { + this.readyState = MockEventSource.CLOSED; + }); + + addEventListener(type: string, handler: (e: MessageEvent) => void) { + const list = this.handlers.get(type) ?? []; + list.push(handler); + this.handlers.set(type, list); + } + + removeEventListener(type: string, handler: (e: MessageEvent) => void) { + const list = this.handlers.get(type); + if (!list) return; + this.handlers.set( + type, + list.filter((h) => h !== handler), + ); + } + + // Test helpers + simulateOpen() { + this.readyState = MockEventSource.OPEN; + this.onopen?.(null); + } + + simulateEvent(type: string, data: unknown) { + const handlers = this.handlers.get(type) ?? []; + const event = { data: JSON.stringify(data) } as MessageEvent; + for (const h of handlers) h(event); + } + + simulateError() { + this.readyState = MockEventSource.CONNECTING; // EventSource reconnects + this.onerror?.(new Event('error')); + } +} + +// ─── Setup ────────────────────────────────────────────────────────────────── + +let lastSource: MockEventSource | null = null; + +function createBus(): EventBus { + lastSource = null; + return new EventBus((_url: string) => { + const source = new MockEventSource(); + lastSource = source; + return source as unknown as EventSource; + }); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('EventBus', () => { + let bus: EventBus; + + beforeEach(() => { + bus = createBus(); + }); + + afterEach(() => { + bus.disconnect(); + }); + + it('starts disconnected', () => { + expect(bus.connected).toBe(false); + }); + + it('connects and reports connected state', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + expect(bus.connected).toBe(true); + }); + + it('dispatches typed events to subscribers', () => { + const handler = vi.fn(); + bus.on('session_activity', handler); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + lastSource!.simulateEvent('session_activity', [{ id: 's1', state: 'working' }]); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith([{ id: 's1', state: 'working' }]); + }); + + it('supports multiple listeners per event type', () => { + const h1 = vi.fn(); + const h2 = vi.fn(); + bus.on('todo_update', h1); + bus.on('todo_update', h2); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + lastSource!.simulateEvent('todo_update', { action: 'refresh' }); + + expect(h1).toHaveBeenCalledOnce(); + expect(h2).toHaveBeenCalledOnce(); + }); + + it('supports multiple event types independently', () => { + const sessionHandler = vi.fn(); + const todoHandler = vi.fn(); + bus.on('session_activity', sessionHandler); + bus.on('todo_update', todoHandler); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + lastSource!.simulateEvent('session_activity', []); + expect(sessionHandler).toHaveBeenCalledOnce(); + expect(todoHandler).not.toHaveBeenCalled(); + + lastSource!.simulateEvent('todo_update', { action: 'refresh' }); + expect(todoHandler).toHaveBeenCalledOnce(); + }); + + it('unsubscribes via returned function', () => { + const handler = vi.fn(); + const unsub = bus.on('loop_status', handler); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + lastSource!.simulateEvent('loop_status', { state: 'running' }); + expect(handler).toHaveBeenCalledOnce(); + + unsub(); + lastSource!.simulateEvent('loop_status', { state: 'idle' }); + expect(handler).toHaveBeenCalledOnce(); // not called again + }); + + it('unsubscribe then resubscribe fires exactly once per event', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + const handler = vi.fn(); + const unsub = bus.on('task_state', handler); + + lastSource!.simulateEvent('task_state', { v: 1 }); + expect(handler).toHaveBeenCalledOnce(); + + unsub(); + handler.mockClear(); + + // Resubscribe — should not get duplicate dispatch + const handler2 = vi.fn(); + bus.on('task_state', handler2); + + lastSource!.simulateEvent('task_state', { v: 2 }); + expect(handler).not.toHaveBeenCalled(); // old handler still unsubbed + expect(handler2).toHaveBeenCalledOnce(); // new handler fires exactly once + }); + + it('subscribes after connect — late listeners work', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + const handler = vi.fn(); + bus.on('health', handler); + + lastSource!.simulateEvent('health', { yapper: 'ok' }); + expect(handler).toHaveBeenCalledWith({ yapper: 'ok' }); + }); + + it('disconnect closes the EventSource', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + const src = lastSource!; + + bus.disconnect(); + expect(src.close).toHaveBeenCalledOnce(); + expect(bus.connected).toBe(false); + }); + + it('disconnect is safe to call when not connected', () => { + expect(() => bus.disconnect()).not.toThrow(); + }); + + it('ensureConnected recreates a CLOSED EventSource', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + const firstSource = lastSource; + + // Simulate EventSource giving up (CLOSED state) + firstSource!.readyState = MockEventSource.CLOSED; + + bus.ensureConnected(); + expect(lastSource).not.toBe(firstSource); // new instance created + }); + + it('ensureConnected no-ops when still connected', () => { + bus.connect('/api/events'); + lastSource!.simulateOpen(); + const firstSource = lastSource; + + bus.ensureConnected(); + expect(lastSource).toBe(firstSource); // same instance + }); + + it('ensureConnected no-ops when reconnecting (CONNECTING)', () => { + bus.connect('/api/events'); + // readyState is still CONNECTING (0) — EventSource is trying to connect + const firstSource = lastSource; + + bus.ensureConnected(); + expect(lastSource).toBe(firstSource); // same instance + }); + + it('fires onConnectionChange callback on open', () => { + const onChange = vi.fn(); + bus.onConnectionChange(onChange); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('fires onConnectionChange callback on error', () => { + const onChange = vi.fn(); + bus.onConnectionChange(onChange); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + onChange.mockClear(); + + lastSource!.simulateError(); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('preserves listeners across reconnect', () => { + const handler = vi.fn(); + bus.on('task_state', handler); + bus.connect('/api/events'); + lastSource!.simulateOpen(); + + // Simulate EventSource dying and being recreated + lastSource!.readyState = MockEventSource.CLOSED; + bus.ensureConnected(); + lastSource!.simulateOpen(); + + lastSource!.simulateEvent('task_state', { tasks: [] }); + expect(handler).toHaveBeenCalledWith({ tasks: [] }); + }); +}); diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index 2527de5c..5386c4e2 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { messagesReducer, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; +import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; import type { MessagesState } from '../src/slices/messages.js'; -import type { FinishedBlock } from '@mitzo/protocol'; +import type { FinishedBlock, FinishedSubagentState, StreamingMessage } from '@mitzo/protocol'; const INITIAL = INITIAL_MESSAGES_STATE; @@ -1140,6 +1140,78 @@ describe('CONNECTION_LOST', () => { }); }); +// ─── finishCurrent (subagent preservation) ────────────────────────────────── + +describe('finishCurrent', () => { + it('preserves the subagent field when converting StreamingBlock to FinishedBlock', () => { + const subagentState = { + messageId: 'sub-msg-1', + blocks: new Map([ + [ + 'sub-b1', + { + blockId: 'sub-b1', + blockType: 'text' as const, + content: 'subagent output', + done: true, + }, + ], + ]), + blockOrder: ['sub-b1'], + running: true as const, + }; + + const current: StreamingMessage = { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Agent', + subagent: subagentState, + }, + ], + ]), + blockOrder: ['b1'], + }; + + const finished = finishCurrent(current); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('sub-msg-1'); + // Verify streaming Map was converted to finished array + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('subagent output'); + }); + + it('works normally when subagent field is absent', () => { + const current: StreamingMessage = { + messageId: 'msg-2', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'text' as const, + content: 'hello', + done: true, + }, + ], + ]), + blockOrder: ['b1'], + }; + + const finished = finishCurrent(current); + expect(finished.blocks[0].subagent).toBeUndefined(); + expect(finished.blocks[0].content).toBe('hello'); + }); +}); + // ─── NATIVE_COMMAND_RESULT ────────────────────────────────────────────────── describe('NATIVE_COMMAND_RESULT', () => { @@ -1156,3 +1228,447 @@ describe('NATIVE_COMMAND_RESULT', () => { expect(msg.blocks[0].content).toContain('Available skills'); }); }); + +// ─── SET_BOOT_CONTEXT ──────────────────────────────────────────────────────── + +describe('SET_BOOT_CONTEXT', () => { + it('sets bootContext from null', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }; + const state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + expect(state.bootContext).toEqual(meta); + }); + + it('overwrites existing bootContext', () => { + const first = { + source: 'local-fallback' as const, + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [] as string[], + }; + const second = { + source: 'contexgin' as const, + sourceCount: 3, + tokenCount: 1500, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: first }); + state = messagesReducer(state, { type: 'SET_BOOT_CONTEXT', bootContext: second }); + expect(state.bootContext).toEqual(second); + }); + + it('is cleared by CLEAR', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 2, + tokenCount: 1000, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + state = messagesReducer(state, { type: 'CLEAR' }); + expect(state.bootContext).toBeNull(); + }); +}); + +// ─── Dedup: foreground recovery vs WS replay ──────────────────────────────── + +describe('MESSAGE_START dedup', () => { + it('skips if messageId already exists in finished messages', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'done' }], + }, + ], + }; + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-1' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + }); + + it('allows MESSAGE_START for a new messageId', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'done' }], + }, + ], + }; + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-2' }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-2'); + }); +}); + +describe('MESSAGE_END dedup', () => { + it('discards current without appending if messageId already in messages', () => { + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'msg-1', + role: 'assistant', + blocks: [{ blockId: 'b1', blockType: 'text', content: 'restored' }], + }, + ], + current: { + messageId: 'msg-1', + blocks: new Map([ + ['b2', { blockId: 'b2', blockType: 'text', content: 'streaming', done: true }], + ]), + blockOrder: ['b2'], + }, + }; + const next = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'msg-1' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + expect(next.messages[0].blocks[0].content).toBe('restored'); + }); + + it('appends normally when messageId is new', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-2', + blocks: new Map([['b1', { blockId: 'b1', blockType: 'text', content: 'new', done: true }]]), + blockOrder: ['b1'], + }, + }; + const next = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'msg-2' }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + expect(next.messages[0].messageId).toBe('msg-2'); + }); +}); + +describe('RESTORE clears stale current', () => { + it('nullifies current when its messageId is in the restored set', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-1', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored }); + expect(next.current).toBeNull(); + expect(next.messages).toHaveLength(1); + }); + + it('preserves current when its messageId is NOT in the restored set', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-new', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-old', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-new'); + }); + + it('nullifies current in interrupted RESTORE too', () => { + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-1', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'msg-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'done' }], + }, + ]; + const next = messagesReducer(state, { type: 'RESTORE', messages: restored, interrupted: true }); + expect(next.current).toBeNull(); + }); + + it('interrupted RESTORE with optimistic user msgs AND stale current', () => { + // Simulate: user sent a follow-up (optimistic), assistant was streaming, + // then interrupted RESTORE arrives with the completed assistant message. + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'restored-1', + role: 'assistant' as const, + blocks: [{ blockId: 'a1', blockType: 'text' as const, content: 'first reply' }], + }, + { + messageId: 'user-optimistic', + role: 'user' as const, + blocks: [{ blockId: 'u1', blockType: 'text' as const, content: 'follow-up' }], + }, + ], + current: { + messageId: 'asst-2', + blocks: new Map(), + blockOrder: [], + }, + }; + const restored = [ + { + messageId: 'restored-1', + role: 'assistant' as const, + blocks: [{ blockId: 'a1', blockType: 'text' as const, content: 'first reply' }], + }, + { + messageId: 'asst-2', + role: 'assistant' as const, + blocks: [{ blockId: 'a2', blockType: 'text' as const, content: 'second reply' }], + }, + ]; + const next = messagesReducer(state, { + type: 'RESTORE', + messages: restored, + interrupted: true, + }); + // current should be cleared (asst-2 is in the restored set) + expect(next.current).toBeNull(); + // optimistic user msg should be preserved and merged + const userMsgs = next.messages.filter((m) => m.role === 'user'); + expect(userMsgs).toHaveLength(1); + expect(userMsgs[0].messageId).toBe('user-optimistic'); + // restored assistant messages should be present + expect(next.messages.some((m) => m.messageId === 'asst-2')).toBe(true); + }); +}); + +describe('MESSAGE_START finalizes current with subagent blocks', () => { + it('finishCurrent converts streaming subagent to finished when new message starts', () => { + // Simulate: assistant is streaming a message with a subagent tool_use block, + // then a new MESSAGE_START arrives — the orphaned current should be finalized + // with subagent blocks correctly converted from Map to array. + const subagentBlocks = new Map([ + [ + 'sub-b1', + { + blockId: 'sub-b1', + blockType: 'text' as const, + content: 'subagent thinking', + done: true, + }, + ], + [ + 'sub-b2', + { + blockId: 'sub-b2', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Bash', + toolId: 'sub-tool-1', + toolInput: 'echo hi', + }, + ], + ]); + + const state: MessagesState = { + ...INITIAL, + current: { + messageId: 'msg-with-subagent', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use' as const, + content: '', + done: true, + toolName: 'Agent', + toolId: 'agent-tool-1', + subagent: { + messageId: 'sub-msg-1', + blocks: subagentBlocks, + blockOrder: ['sub-b1', 'sub-b2'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // New MESSAGE_START should finalize the current (with subagent) and start fresh + const next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'msg-new' }); + + // Orphaned message with subagent should be finalized into messages[] + expect(next.messages).toHaveLength(1); + expect(next.messages[0].messageId).toBe('msg-with-subagent'); + + // Subagent should be converted from streaming (Map) to finished (array) + const sub = next.messages[0].blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('sub-msg-1'); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(2); + expect(sub.blocks[0].content).toBe('subagent thinking'); + expect(sub.blocks[1].toolName).toBe('Bash'); + expect(sub.blocks[1].toolId).toBe('sub-tool-1'); + + // New current should be clean + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('msg-new'); + expect(next.current!.blockOrder).toHaveLength(0); + }); +}); + +describe('foreground recovery race — full sequence', () => { + it('RESTORE then WS replay of same message does not duplicate', () => { + // Simulate: user sends, assistant streams, iOS backgrounds, foreground returns + // 1. USER_SEND adds optimistic user message + let state = messagesReducer(INITIAL, { + type: 'USER_SEND', + text: 'hello', + clientMsgId: 'user-abc', + }); + // 2. MESSAGE_START creates current + state = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-1' }); + state = messagesReducer(state, { + type: 'BLOCK_START', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + state = messagesReducer(state, { + type: 'BLOCK_DELTA', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + delta: 'partial', + }); + expect(state.messages).toHaveLength(1); // user msg + expect(state.current).not.toBeNull(); + + // 3. RESTORE fires (foreground recovery) — server has the completed conversation + state = messagesReducer(state, { + type: 'RESTORE', + messages: [ + { + messageId: 'user-abc', + role: 'user' as const, + blocks: [{ blockId: 'u1', blockType: 'text' as const, content: 'hello' }], + }, + { + messageId: 'asst-1', + role: 'assistant' as const, + blocks: [{ blockId: 'b1', blockType: 'text' as const, content: 'full response' }], + }, + ], + }); + // RESTORE should replace messages AND clear stale current + expect(state.messages).toHaveLength(2); + expect(state.current).toBeNull(); + + // 4. WS replay delivers the same message_start + message_end + state = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-1' }); + // MESSAGE_START should be a no-op (dedup) + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + + // 5. BLOCK events after suppressed MESSAGE_START are safe no-ops + state = messagesReducer(state, { + type: 'BLOCK_START', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + state = messagesReducer(state, { + type: 'BLOCK_DELTA', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + delta: 'replayed', + }); + state = messagesReducer(state, { + type: 'BLOCK_END', + messageId: 'asst-1', + blockId: 'b1', + blockType: 'text', + }); + // Still no current, still 2 messages + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + + // 6. MESSAGE_END for the replayed message — should be safe no-op + state = messagesReducer(state, { type: 'MESSAGE_END', messageId: 'asst-1' }); + expect(state.current).toBeNull(); + expect(state.messages).toHaveLength(2); + expect(state.messages[1].blocks[0].content).toBe('full response'); + }); + + it('multi-turn: only the replayed message is deduplicated, earlier ones kept', () => { + // Start with a completed first turn + const state: MessagesState = { + ...INITIAL, + messages: [ + { + messageId: 'user-1', + role: 'user', + blocks: [{ blockId: 'u1', blockType: 'text', content: 'first' }], + }, + { + messageId: 'asst-1', + role: 'assistant', + blocks: [{ blockId: 'a1', blockType: 'text', content: 'reply 1' }], + }, + { + messageId: 'user-2', + role: 'user', + blocks: [{ blockId: 'u2', blockType: 'text', content: 'second' }], + }, + { + messageId: 'asst-2', + role: 'assistant', + blocks: [{ blockId: 'a2', blockType: 'text', content: 'reply 2' }], + }, + ], + }; + + // WS replay tries to re-deliver only asst-2 + let next = messagesReducer(state, { type: 'MESSAGE_START', messageId: 'asst-2' }); + expect(next.current).toBeNull(); // dedup blocked it + expect(next.messages).toHaveLength(4); // all 4 still there + + // A genuinely new message should still work + next = messagesReducer(next, { type: 'MESSAGE_START', messageId: 'asst-3' }); + expect(next.current).not.toBeNull(); + expect(next.current!.messageId).toBe('asst-3'); + }); +}); diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 23076d8f..a59d42f6 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -5,7 +5,7 @@ import type { ProtocolCallbacks, ProtocolParserState } from '../src/protocol-par function makeState(overrides?: Partial): ProtocolParserState { return { currentSessionId: undefined, - pendingSend: null, + pendingSend: [], ...overrides, }; } @@ -155,14 +155,20 @@ describe('session lifecycle', () => { ]); }); - it('session_end clears pending send and queues it', () => { - const state = makeState({ pendingSend: { type: 'send', prompt: 'follow-up' } }); + it('session_end dequeues first pending send and queues it', () => { + const state = makeState({ + pendingSend: [ + { type: 'send', prompt: 'follow-up' }, + { type: 'send', prompt: 'second' }, + ], + }); const cb = makeCallbacks(); const r = parseServerMessage({ type: 'session_end', sessionId: 'sid' }, state, cb, POOL_KEY); expect(r.messagesActions).toContainEqual({ type: 'SESSION_END', sessionId: 'sid' }); expect(r.messagesActions).toContainEqual({ type: 'SET_RUNNING', running: true }); expect(cb.sendQueued).toHaveBeenCalledWith(POOL_KEY, { type: 'send', prompt: 'follow-up' }); - expect(state.pendingSend).toBeNull(); + // Second message stays queued + expect(state.pendingSend).toEqual([{ type: 'send', prompt: 'second' }]); }); }); @@ -369,10 +375,10 @@ describe('error handling', () => { expect(r.messagesActions).toEqual([{ type: 'ERROR', error: 'Something broke' }]); }); - it('error clears pendingSend', () => { - const state = makeState({ pendingSend: { type: 'send', prompt: 'test' } }); + it('error clears pendingSend queue', () => { + const state = makeState({ pendingSend: [{ type: 'send', prompt: 'test' }] }); parseServerMessage({ type: 'error', error: 'fail' }, state, makeCallbacks(), POOL_KEY); - expect(state.pendingSend).toBeNull(); + expect(state.pendingSend).toEqual([]); }); }); @@ -609,6 +615,75 @@ describe('reconnected', () => { parseServerMessage({ type: 'reconnected', sessions: [] }, makeState(), cb, POOL_KEY); expect(onReconnected).toHaveBeenCalled(); }); + + it('dispatches SET_RUNNING false when active session reports not running', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: false }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toContainEqual({ type: 'SET_RUNNING', running: false }); + }); + + it('does not dispatch SET_RUNNING when active session is still running', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: true }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); + + it('no-ops when no currentSessionId', () => { + const state = makeState({ currentSessionId: undefined }); + const r = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', replayed: 0, running: false }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); + + it('no-ops when sessions field is undefined (backward compat)', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + const r = parseServerMessage({ type: 'reconnected' }, state, makeCallbacks(), POOL_KEY); + expect(r.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); + + it('no-ops when sessions has invalid shape (runtime validation)', () => { + const state = makeState({ currentSessionId: 'sid-1' }); + // Invalid: sessions is not an array + const r1 = parseServerMessage( + { type: 'reconnected', sessions: { invalid: 'shape' } }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r1.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + + // Invalid: array contains entries missing required fields + const r2 = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1' }] }, // missing running field + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r2.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + + // Invalid: running field has wrong type + const r3 = parseServerMessage( + { type: 'reconnected', sessions: [{ sessionId: 'sid-1', running: 'yes' }] }, + state, + makeCallbacks(), + POOL_KEY, + ); + expect(r3.messagesActions).not.toContainEqual(expect.objectContaining({ type: 'SET_RUNNING' })); + }); }); // ─── error handling (session expired) ──────────────────────────────────────── @@ -627,3 +702,146 @@ describe('error with No conversation found', () => { expect(r.messagesActions).toContainEqual(expect.objectContaining({ type: 'ERROR' })); }); }); + +// ─── boot_context ───────────────────────────────────────────────────────────── + +describe('boot_context', () => { + it('maps boot_context to SET_BOOT_CONTEXT with validated fields', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toEqual([ + { + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + }, + ]); + }); + + it('normalizes unknown source to local-fallback', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'unknown-engine', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { source: 'local-fallback' }, + }); + }); + + it('defaults missing numeric fields to 0 and sources to empty array', () => { + const r = parseServerMessage( + { type: 'boot_context', source: 'contexgin' }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toEqual({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + }); + }); + + it('filters out non-string elements from sources array', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 3, + tokenCount: 1000, + trimmedCount: 0, + sources: ['CLAUDE.md', 42, null, undefined, { relativePath: 'foo.md' }, 'README.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: ['CLAUDE.md', 'README.md'], + }, + }); + }); + + it('handles sources as a non-array value gracefully', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 1, + tokenCount: 500, + trimmedCount: 0, + sources: 'not-an-array', + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: [], + }, + }); + }); +}); + +// ─── Subagent cancellation ─────────────────────────────────────────────────── + +describe('subagent_cancelled', () => { + it('maps to SUBAGENT_END with summary Cancelled', () => { + const r = parseServerMessage( + { + type: 'subagent_cancelled', + v: 2, + ts: Date.now(), + parentBlockId: 'blk-parent-1', + subagentMessageId: 'msg-sub-1', + taskId: 'task-123', + } as any, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toHaveLength(1); + expect(r.messagesActions[0]).toEqual({ + type: 'SUBAGENT_END', + parentBlockId: 'blk-parent-1', + summary: 'Cancelled', + }); + }); +}); diff --git a/packages/client/__tests__/store.test.ts b/packages/client/__tests__/store.test.ts index f83fa1ef..2ba1c5b0 100644 --- a/packages/client/__tests__/store.test.ts +++ b/packages/client/__tests__/store.test.ts @@ -1142,4 +1142,69 @@ describe('foreground recovery', () => { // The original 1 live message persists since RESTORE merges. expect(store.getState().messages.messages).toHaveLength(1); }); + + it('preserves streaming state when REST returns empty array', async () => { + const transport = mockTransport(); + (transport.fetch as ReturnType).mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/messages')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + text: () => Promise.resolve(''), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + text: () => Promise.resolve(''), + }); + }); + const store = createReadyStore(transport); + + store.setState((s) => ({ + sessions: { ...s.sessions, active: 'sess-1' }, + })); + + // Simulate an in-progress streaming message + const streamingCurrent = { + messageId: 'msg-streaming', + blocks: new Map([ + [ + 'b0', + { + blockId: 'b0', + blockType: 'text' as const, + content: 'Let me check the memory vault.', + done: false, + }, + ], + ]), + blockOrder: ['b0'], + }; + store.setState((s) => ({ + messages: { + ...s.messages, + messages: [ + { + messageId: 'user-1', + role: 'user' as const, + timestamp: Date.now(), + blocks: [{ blockId: 'u0', blockType: 'text' as const, content: 'hello' }], + }, + ], + current: streamingCurrent, + }, + })); + + // Foreground fires — REST returns empty (timing gap) + lastWs.simulateMessage({ type: '_foreground' }); + await new Promise((r) => setTimeout(r, 50)); + + // Streaming state must survive — not wiped + const state = store.getState().messages; + expect(state.current).not.toBeNull(); + expect(state.current?.messageId).toBe('msg-streaming'); + expect(state.messages).toHaveLength(1); + expect(state.messages[0].messageId).toBe('user-1'); + }); }); diff --git a/packages/client/__tests__/subagent-reducer.test.ts b/packages/client/__tests__/subagent-reducer.test.ts new file mode 100644 index 00000000..2910bc45 --- /dev/null +++ b/packages/client/__tests__/subagent-reducer.test.ts @@ -0,0 +1,512 @@ +import { describe, it, expect } from 'vitest'; +import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js'; +import type { + StreamingBlock, + StreamingSubagentState, + FinishedSubagentState, +} from '@mitzo/protocol'; + +describe('Subagent Reducer Actions', () => { + it('SUBAGENT_START initializes subagent state on parent tool block', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_START' as const, + parentBlockId: 'b1', + subagentMessageId: 'msg-sub-1', + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub).toBeDefined(); + expect(sub.messageId).toBe('msg-sub-1'); + expect(sub.running).toBe(true); + expect(sub.blocks.size).toBe(0); + }); + + it('SUBAGENT_BLOCK_START adds block to subagent state', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map(), + blockOrder: [], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_BLOCK_START' as const, + parentBlockId: 'b1', + blockId: 'b-sub-1', + blockType: 'thinking' as const, + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.size).toBe(1); + expect(sub.blockOrder).toEqual(['b-sub-1']); + expect(sub.blocks.get('b-sub-1')?.blockType).toBe('thinking'); + }); + + it('SUBAGENT_BLOCK_DELTA appends to subagent block content', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Hello', + done: false, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_BLOCK_DELTA' as const, + parentBlockId: 'b1', + blockId: 'b-sub-1', + delta: ' world', + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-1')?.content).toBe('Hello world'); + }); + + it('SUBAGENT_BLOCK_END marks subagent block as done', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Done', + done: false, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_BLOCK_END' as const, + parentBlockId: 'b1', + blockId: 'b-sub-1', + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-1')?.done).toBe(true); + }); + + it('SUBAGENT_TOOL_RESULT patches tool result in subagent block', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-2', + { + blockId: 'b-sub-2', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Read', + toolId: 'tool-read-1', + }, + ], + ]), + blockOrder: ['b-sub-2'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_TOOL_RESULT' as const, + parentBlockId: 'b1', + toolId: 'tool-read-1', + result: 'file contents', + isError: false, + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState; + expect(sub.blocks.get('b-sub-2')?.toolResult).toBe('file contents'); + expect(sub.blocks.get('b-sub-2')?.toolError).toBe(false); + }); + + it('SUBAGENT_END finalizes subagent state with summary and usage', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Result', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + const action = { + type: 'SUBAGENT_END' as const, + parentBlockId: 'b1', + summary: 'Search complete', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }; + + const newState = messagesReducer(state, action); + + const sub = newState.current?.blocks.get('b1')?.subagent as FinishedSubagentState; + expect(sub.running).toBeUndefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.summary).toBe('Search complete'); + expect(sub.usage?.inputTokens).toBe(100); + }); + + it('finishCurrent converts already-finished subagent via passthrough', () => { + // Simulate full lifecycle: SUBAGENT_START → blocks → SUBAGENT_END → MESSAGE_END + let state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Done', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // End the subagent first + state = messagesReducer(state, { + type: 'SUBAGENT_END', + parentBlockId: 'b1', + summary: 'All done', + }); + + // Now finish the message — finishSubagent should passthrough the already-finished state + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('Done'); + expect(sub.summary).toBe('All done'); + }); + + it('finishCurrent converts still-streaming subagent to finished', () => { + // Edge case: MESSAGE_END arrives without SUBAGENT_END + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Partial', + done: false, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // Finish message without SUBAGENT_END — finishSubagent must convert Map→array + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub).toBeDefined(); + expect(Array.isArray(sub.blocks)).toBe(true); + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].content).toBe('Partial'); + expect(sub.summary).toBeUndefined(); + }); + + it('finishSubagent skips stale blockOrder entries not in the Map', () => { + const state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + // blockOrder references 'ghost' which is NOT in the Map + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Real', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1', 'ghost'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // Should not throw — stale entry is filtered out + const finished = finishCurrent(state.current!); + const sub = finished.blocks[0].subagent as FinishedSubagentState; + expect(sub.blocks).toHaveLength(1); + expect(sub.blocks[0].blockId).toBe('b-sub-1'); + }); + + it('drops streaming events after SUBAGENT_END (out-of-order delivery)', () => { + let state = { + ...INITIAL_MESSAGES_STATE, + current: { + messageId: 'msg-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: true, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b-sub-1', + { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Done', + done: true, + }, + ], + ]), + blockOrder: ['b-sub-1'], + running: true as const, + }, + }, + ], + ]), + blockOrder: ['b1'], + }, + }; + + // End the subagent + state = messagesReducer(state, { + type: 'SUBAGENT_END', + parentBlockId: 'b1', + summary: 'Complete', + }); + + // Late-arriving streaming event should be silently dropped + const afterDelta = messagesReducer(state, { + type: 'SUBAGENT_BLOCK_DELTA', + parentBlockId: 'b1', + blockId: 'b-sub-1', + delta: ' extra', + }); + + // State unchanged — event was dropped by getStreamingSubagent guard + expect(afterDelta).toBe(state); + }); +}); diff --git a/packages/client/src/api-client.ts b/packages/client/src/api-client.ts index 268713a4..970d448a 100644 --- a/packages/client/src/api-client.ts +++ b/packages/client/src/api-client.ts @@ -56,6 +56,7 @@ export interface SessionMetaResponse { totalTokens: number; totalCostUsd: number; numTurns: number; + telosTaskId: string | null; } export interface CalendarData { diff --git a/packages/client/src/event-bus.ts b/packages/client/src/event-bus.ts new file mode 100644 index 00000000..840301ed --- /dev/null +++ b/packages/client/src/event-bus.ts @@ -0,0 +1,129 @@ +// SSE client for broadcast events (session state, tasks, health). + +// EventSource readyState constants. +const ES_OPEN = 1; +const ES_CLOSED = 2; + +export type EventBusListener = (data: unknown) => void; +export type EventSourceFactory = (url: string) => EventSource; +export type ConnectionChangeCallback = (connected: boolean) => void; + +export class EventBus { + private source: EventSource | null = null; + private url: string | null = null; + private listeners = new Map>(); + private connectionChangeListeners = new Set(); + private createEventSource: EventSourceFactory; + + constructor(factory?: EventSourceFactory) { + this.createEventSource = + factory ?? ((url: string) => new EventSource(url, { withCredentials: true })); + } + + connect(url: string): void { + if (this.source) return; + this.url = url; + this.createSource(); + } + + // Listeners survive EventSource reconnects — dispatch goes through the Set. + on(event: string, listener: EventBusListener): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + // First listener for this event type — register the dispatch handler + if (this.source) { + this.registerEventDispatch(event); + } + } + set.add(listener); + + return () => { + set!.delete(listener); + }; + } + + onConnectionChange(cb: ConnectionChangeCallback): () => void { + this.connectionChangeListeners.add(cb); + return () => this.connectionChangeListeners.delete(cb); + } + + // Recreate EventSource if CLOSED (gave up). No-op if still alive. + ensureConnected(): void { + if (!this.url) return; + if (!this.source || this.source.readyState === ES_CLOSED) { + if (this.source) { + try { + this.source.close(); + } catch { + /* best effort */ + } + } + this.source = null; + this.createSource(); + } + } + + disconnect(): void { + if (this.source) { + try { + this.source.close(); + } catch { + /* best effort */ + } + this.source = null; + } + } + + get connected(): boolean { + return this.source?.readyState === ES_OPEN; + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private createSource(): void { + if (!this.url) return; + + const source = this.createEventSource(this.url); + this.source = source; + + source.onopen = () => { + this.notifyConnectionChange(true); + }; + + source.onerror = () => { + this.notifyConnectionChange(false); + }; + + for (const [event, set] of this.listeners) { + if (set.size > 0) this.registerEventDispatch(event); + } + } + + private registerEventDispatch(event: string): void { + if (!this.source) return; + this.source.addEventListener(event, ((e: MessageEvent) => { + const set = this.listeners.get(event); + if (!set || set.size === 0) return; + try { + const data = JSON.parse(e.data); + for (const listener of set) { + listener(data); + } + } catch { + // Malformed JSON — skip + } + }) as EventListener); + } + + private notifyConnectionChange(connected: boolean): void { + for (const cb of this.connectionChangeListeners) { + try { + cb(connected); + } catch { + /* listener error — don't propagate */ + } + } + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index caf0f495..5888b322 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,7 +17,12 @@ export type { } from './store.js'; // Slices — state shapes and types -export type { MessagesState, MessagesAction, ActiveWorktree } from './slices/messages.js'; +export type { + MessagesState, + MessagesAction, + ActiveWorktree, + BootContextMeta, +} from './slices/messages.js'; export { messagesReducer, INITIAL_MESSAGES_STATE } from './slices/messages.js'; export type { SessionsState } from './slices/sessions.js'; export type { ConnectionState, ConnectionStatus } from './slices/connection.js'; @@ -59,3 +64,11 @@ export type { MitzoConnectionConfig, ConnectionListener } from './connection.js' // WS connection (v1 — legacy, used by frontend ws-pool consumers) export { WsPool } from './ws-connection.js'; export type { WsPoolConfig, WebSocketLike, MsgListener } from './ws-connection.js'; + +// SSE event bus (broadcast events) +export { EventBus } from './event-bus.js'; +export type { + EventBusListener, + EventSourceFactory, + ConnectionChangeCallback, +} from './event-bus.js'; diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 257d63eb..399d0e47 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -63,8 +63,8 @@ export interface ProtocolParserState { /** Currently tracked session ID (used for expiry detection). */ currentSessionId: string | undefined; - /** Queued message to send after current session ends. */ - pendingSend: Record | null; + /** Queued messages to send after current session ends (FIFO). */ + pendingSend: Record[]; } // ─── Parser result ─────────────────────────────────────────────────────────── @@ -113,10 +113,32 @@ export function parseServerMessage( // ── v2 handshake events ──────────────────────────────────────────────── - case 'reconnected': + case 'reconnected': { result.connectionUpdate = { status: 'connected' }; + // Apply authoritative running state from the server for the active session. + // Validate runtime shape: sessions must be an array, and each entry must have + // sessionId (string) and running (boolean). Explicit running === false check + // guards against undefined/missing field. + const sessions = msg.sessions as unknown; + if ( + Array.isArray(sessions) && + state.currentSessionId && + sessions.every( + (s): s is { sessionId: string; running: boolean } => + typeof s === 'object' && + s !== null && + typeof s.sessionId === 'string' && + typeof s.running === 'boolean', + ) + ) { + const active = sessions.find((s) => s.sessionId === state.currentSessionId); + if (active && active.running === false) { + result.messagesActions.push({ type: 'SET_RUNNING', running: false }); + } + } callbacks.onReconnected?.(); break; + } case 'session_takeover': result.messagesActions.push({ type: 'SET_RUNNING', running: false }); @@ -172,6 +194,23 @@ export function parseServerMessage( }); break; + case 'boot_context': { + const source = msg.source === 'contexgin' ? 'contexgin' : 'local-fallback'; + const sourceCount = typeof msg.sourceCount === 'number' ? msg.sourceCount : 0; + const tokenCount = typeof msg.tokenCount === 'number' ? msg.tokenCount : 0; + const trimmedCount = typeof msg.trimmedCount === 'number' ? msg.trimmedCount : 0; + // Validate each element is a string — filter out non-string entries + const rawSources = Array.isArray(msg.sources) ? msg.sources : []; + const sources: string[] = rawSources.filter( + (s: unknown): s is string => typeof s === 'string', + ); + result.messagesActions.push({ + type: 'SET_BOOT_CONTEXT', + bootContext: { source, sourceCount, tokenCount, trimmedCount, sources }, + }); + break; + } + case 'worktree_opened': result.messagesActions.push({ type: 'WORKTREE_OPENED', @@ -267,9 +306,8 @@ export function parseServerMessage( if (msg.sessionId && !state.currentSessionId) { callbacks.onSessionAssigned(msg.sessionId as string); } - const pending = state.pendingSend; + const pending = state.pendingSend.shift(); if (pending) { - state.pendingSend = null; result.messagesActions.push({ type: 'SET_RUNNING', running: true }); // v2 path: use onSendQueued callback (no pool key needed) if (callbacks.onSendQueued) { @@ -313,6 +351,17 @@ export function parseServerMessage( }); break; + case 'session_close_ack': + if (msg.accepted) { + result.messagesActions.push({ + type: 'NATIVE_COMMAND_RESULT', + command: 'close', + content: 'Session closing... The agent will commit work and write a summary.', + }); + result.messagesActions.push({ type: 'SET_RUNNING', running: false }); + } + break; + case 'skill_invoked': break; @@ -335,7 +384,7 @@ export function parseServerMessage( const errorMsg = msg.error as string; callbacks.setWsRunning?.(poolKey, false); - state.pendingSend = null; + state.pendingSend = []; result.messagesActions.push({ type: 'ERROR', error: errorMsg || 'Unknown error', @@ -417,6 +466,80 @@ export function parseServerMessage( case 'inbox_updated': result.inboxRefresh = true; break; + + // Subagent lifecycle messages + case 'subagent_start': + result.messagesActions.push({ + type: 'SUBAGENT_START', + parentBlockId: msg.parentBlockId as string, + subagentMessageId: msg.subagentMessageId as string, + }); + break; + + case 'subagent_block_start': + result.messagesActions.push({ + type: 'SUBAGENT_BLOCK_START', + parentBlockId: msg.parentBlockId as string, + blockId: msg.blockId as string, + blockType: msg.blockType as BlockType, + toolName: msg.toolName as string | undefined, + }); + break; + + case 'subagent_block_delta': + result.messagesActions.push({ + type: 'SUBAGENT_BLOCK_DELTA', + parentBlockId: msg.parentBlockId as string, + blockId: msg.blockId as string, + delta: msg.delta as string, + }); + break; + + case 'subagent_block_end': + result.messagesActions.push({ + type: 'SUBAGENT_BLOCK_END', + parentBlockId: msg.parentBlockId as string, + blockId: msg.blockId as string, + toolName: msg.toolName as string | undefined, + toolId: msg.toolId as string | undefined, + input: msg.input as string | undefined, + rawInput: msg.rawInput as RawToolInput | undefined, + }); + break; + + case 'subagent_tool_result': + result.messagesActions.push({ + type: 'SUBAGENT_TOOL_RESULT', + parentBlockId: msg.parentBlockId as string, + toolId: msg.toolId as string, + result: msg.result as string, + isError: (msg.isError as boolean) ?? false, + }); + break; + + case 'subagent_end': + result.messagesActions.push({ + type: 'SUBAGENT_END', + parentBlockId: msg.parentBlockId as string, + summary: msg.summary as string | undefined, + usage: msg.usage as + | { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + } + | undefined, + }); + break; + + case 'subagent_cancelled': + result.messagesActions.push({ + type: 'SUBAGENT_END', + parentBlockId: msg.parentBlockId as string, + summary: 'Cancelled', + }); + break; } return result; diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index e7523a28..ab95d679 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -13,6 +13,8 @@ import type { PermissionRequest, RawToolInput, BlockType, + StreamingSubagentState, + FinishedSubagentState, } from '@mitzo/protocol'; // ─── State ─────────────────────────────────────────────────────────────────── @@ -22,6 +24,14 @@ export interface ActiveWorktree { path: string; } +export interface BootContextMeta { + source: 'contexgin' | 'local-fallback'; + sourceCount: number; + tokenCount: number; + trimmedCount: number; + sources: string[]; +} + export interface MessagesState { messages: FinishedMessage[]; current: StreamingMessage | null; @@ -32,6 +42,7 @@ export interface MessagesState { wtId: string | null; activeWorktrees: ActiveWorktree[]; sessionContext: string | null; + bootContext: BootContextMeta | null; } export const INITIAL_MESSAGES_STATE: MessagesState = { @@ -44,6 +55,7 @@ export const INITIAL_MESSAGES_STATE: MessagesState = { wtId: null, activeWorktrees: [], sessionContext: null, + bootContext: null, }; // ─── Actions ───────────────────────────────────────────────────────────────── @@ -77,6 +89,43 @@ export type MessagesAction = } | { type: 'MESSAGE_END'; messageId: string; sessionId?: string } | { type: 'SESSION_END'; sessionId?: string } + // Subagent events + | { type: 'SUBAGENT_START'; parentBlockId: string; subagentMessageId: string } + | { + type: 'SUBAGENT_BLOCK_START'; + parentBlockId: string; + blockId: string; + blockType: BlockType; + toolName?: string; + } + | { type: 'SUBAGENT_BLOCK_DELTA'; parentBlockId: string; blockId: string; delta: string } + | { + type: 'SUBAGENT_BLOCK_END'; + parentBlockId: string; + blockId: string; + toolName?: string; + toolId?: string; + input?: string; + rawInput?: RawToolInput; + } + | { + type: 'SUBAGENT_TOOL_RESULT'; + parentBlockId: string; + toolId: string; + result: string; + isError: boolean; + } + | { + type: 'SUBAGENT_END'; + parentBlockId: string; + summary?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }; + } // Reattach snapshot | { type: 'MESSAGE_SNAPSHOT'; messageId: string; blocks: FinishedBlock[] } // Session / UI lifecycle @@ -98,10 +147,44 @@ export type MessagesAction = | { type: 'WORKTREE_OPENED'; repoName: string; path: string } | { type: 'NATIVE_COMMAND_RESULT'; command: string; content: string } | { type: 'SET_SESSION_CONTEXT'; context: string } + | { type: 'SET_BOOT_CONTEXT'; bootContext: BootContextMeta } | { type: 'CLEAR' }; // ─── Helpers ───────────────────────────────────────────────────────────────── +/** Narrow a block's subagent to StreamingSubagentState, or null if already finished. */ +function getStreamingSubagent(block: StreamingBlock): StreamingSubagentState | null { + if (!block.subagent || !('blockOrder' in block.subagent)) return null; + return block.subagent; +} + +function finishSubagent( + sub: StreamingSubagentState | FinishedSubagentState, +): FinishedSubagentState { + // Already finished (SUBAGENT_END already fired) + if (Array.isArray(sub.blocks)) return sub as FinishedSubagentState; + + // Still streaming — convert Map to FinishedBlock[] + const streaming = sub as StreamingSubagentState; + return { + messageId: streaming.messageId, + blocks: streaming.blockOrder + .map((blockId) => streaming.blocks.get(blockId)) + .filter((b): b is StreamingBlock => b != null) + .map((b) => ({ + blockId: b.blockId, + blockType: b.blockType, + content: b.content, + toolName: b.toolName, + toolId: b.toolId, + toolInput: b.toolInput, + rawInput: b.rawInput, + toolResult: b.toolResult, + toolError: b.toolError, + })), + }; +} + export function finishCurrent(current: StreamingMessage): FinishedMessage { const blocks: FinishedBlock[] = current.blockOrder.map((blockId) => { const b = current.blocks.get(blockId)!; @@ -115,6 +198,7 @@ export function finishCurrent(current: StreamingMessage): FinishedMessage { rawInput: b.rawInput, toolResult: b.toolResult, toolError: b.toolError, + subagent: b.subagent ? finishSubagent(b.subagent) : undefined, }; }); return { messageId: current.messageId, role: 'assistant', blocks, timestamp: Date.now() }; @@ -153,6 +237,10 @@ export function patchToolResult( export function messagesReducer(state: MessagesState, action: MessagesAction): MessagesState { switch (action.type) { case 'MESSAGE_START': { + // Dedup: skip if this message was already restored (e.g. WS replay after RESTORE) + if (state.messages.some((m) => m.messageId === action.messageId)) { + return state; + } const base = state.current ? { ...state, messages: [...state.messages, finishCurrent(state.current)] } : state; @@ -224,7 +312,11 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M } case 'MESSAGE_END': { - if (!state.current) return { ...state }; + if (!state.current) return state; + // Dedup: if this message was already restored, discard the streaming copy + if (state.messages.some((m) => m.messageId === state.current!.messageId)) { + return { ...state, current: null }; + } const finished = finishCurrent(state.current); return { ...state, messages: [...state.messages, finished], current: null }; } @@ -338,6 +430,9 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SET_SESSION_CONTEXT': return { ...state, sessionContext: action.context }; + case 'SET_BOOT_CONTEXT': + return { ...state, bootContext: action.bootContext }; + case 'CLEAR': return { ...INITIAL_MESSAGES_STATE }; @@ -386,9 +481,15 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M merged.splice(insertAfter + 1, 0, opt); } merged.push(notice); - return { ...state, messages: merged }; + const currentStale = + state.current && merged.some((m) => m.messageId === state.current!.messageId); + return { ...state, messages: merged, current: currentStale ? null : state.current }; } - return { ...state, messages: valid }; + // Clear current if the restored set already contains it (prevents + // MESSAGE_END from re-inserting a message that RESTORE already has). + const currentStale = + state.current && valid.some((m) => m.messageId === state.current!.messageId); + return { ...state, messages: valid, current: currentStale ? null : state.current }; } case 'USER_MESSAGE_RECEIVED': { @@ -466,6 +567,175 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M ], }; + // Subagent reducer cases + case 'SUBAGENT_START': { + if (!state.current) return state; + const block = state.current.blocks.get(action.parentBlockId); + if (!block) return state; + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { + ...block, + subagent: { + messageId: action.subagentMessageId, + blocks: new Map(), + blockOrder: [], + running: true, + }, + }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + + case 'SUBAGENT_BLOCK_START': { + if (!state.current) return state; + const parentBlock = state.current.blocks.get(action.parentBlockId); + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; + + const newBlock: StreamingBlock = { + blockId: action.blockId, + blockType: action.blockType, + content: '', + done: false, + ...(action.toolName ? { toolName: action.toolName } : {}), + }; + + const newSubBlocks = new Map(sub.blocks); + newSubBlocks.set(action.blockId, newBlock); + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { + ...parentBlock, + subagent: { + ...sub, + blocks: newSubBlocks, + blockOrder: [...sub.blockOrder, action.blockId], + }, + }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + + case 'SUBAGENT_BLOCK_DELTA': { + if (!state.current) return state; + const parentBlock = state.current.blocks.get(action.parentBlockId); + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; + + const subBlock = sub.blocks.get(action.blockId); + if (!subBlock) return state; + + const newSubBlocks = new Map(sub.blocks); + newSubBlocks.set(action.blockId, { + ...subBlock, + content: subBlock.content + action.delta, + }); + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { + ...parentBlock, + subagent: { ...sub, blocks: newSubBlocks }, + }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + + case 'SUBAGENT_BLOCK_END': { + if (!state.current) return state; + const parentBlock = state.current.blocks.get(action.parentBlockId); + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; + + const subBlock = sub.blocks.get(action.blockId); + if (!subBlock) return state; + + const newSubBlocks = new Map(sub.blocks); + newSubBlocks.set(action.blockId, { + ...subBlock, + done: true, + ...(action.toolName ? { toolName: action.toolName } : {}), + ...(action.toolId ? { toolId: action.toolId } : {}), + ...(action.input ? { toolInput: action.input } : {}), + ...(action.rawInput ? { rawInput: action.rawInput } : {}), + }); + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { + ...parentBlock, + subagent: { ...sub, blocks: newSubBlocks }, + }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + + case 'SUBAGENT_TOOL_RESULT': { + if (!state.current) return state; + const parentBlock = state.current.blocks.get(action.parentBlockId); + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; + + // Find the tool block with matching toolId + for (const [blockId, subBlock] of sub.blocks) { + if (subBlock.toolId === action.toolId) { + const newSubBlocks = new Map(sub.blocks); + newSubBlocks.set(blockId, { + ...subBlock, + toolResult: action.result, + toolError: action.isError, + }); + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { + ...parentBlock, + subagent: { ...sub, blocks: newSubBlocks }, + }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + } + + return state; + } + + case 'SUBAGENT_END': { + if (!state.current) return state; + const parentBlock = state.current.blocks.get(action.parentBlockId); + if (!parentBlock) return state; + const sub = getStreamingSubagent(parentBlock); + if (!sub) return state; + + // Convert streaming subagent state to finished state + const finished: FinishedSubagentState = { + messageId: sub.messageId, + blocks: sub.blockOrder + .map((blockId) => sub.blocks.get(blockId)) + .filter((b): b is StreamingBlock => b != null) + .map((b) => ({ + blockId: b.blockId, + blockType: b.blockType, + content: b.content, + toolName: b.toolName, + toolId: b.toolId, + toolInput: b.toolInput, + rawInput: b.rawInput, + toolResult: b.toolResult, + toolError: b.toolError, + })), + summary: action.summary, + usage: action.usage, + }; + + const newBlocks = new Map(state.current.blocks); + newBlocks.set(action.parentBlockId, { ...parentBlock, subagent: finished }); + + return { ...state, current: { ...state.current, blocks: newBlocks } }; + } + default: return state; } diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index d9133744..80245552 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -49,11 +49,13 @@ export interface SendMessageOptions { cwd?: string; extraTools?: string; isolation?: boolean; + telosTaskId?: string; } export interface PendingSession { prompt: string; context: string; + telosTaskId?: string; } export interface MitzoStoreState { @@ -83,6 +85,7 @@ export interface MitzoStoreState { sendMessage(text: string, opts?: SendMessageOptions): void; interruptMessage(text: string, opts?: SendMessageOptions): void; stopGeneration(): void; + closeSession(): void; respondToPermission(permId: string, decision: 'once' | 'always' | 'deny'): void; setMode(mode: MitzoMode): void; setModel(modelId: string): void; @@ -167,7 +170,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi; } = { currentSessionId: undefined, - pendingSend: null, + pendingSend: [], }; let recoveryInFlight = false; @@ -183,7 +186,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi 0 ? messagesReducer(s.messages, { type: 'RESTORE', messages: msgs }) - : { ...s.messages, messages: [], current: null }, + : s.messages, // preserve state — empty REST response doesn't mean state is invalid })); } }) @@ -266,7 +269,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi { - const pending = parserState.pendingSend; - if (!pending) return; - parserState.pendingSend = null; - parserState.pendingSendTimer = undefined; + parserState.pendingSendTimer = setTimeout(function drainOne() { + const pending = parserState.pendingSend.shift(); + if (!pending) { + parserState.pendingSendTimer = undefined; + return; + } set((s) => ({ messages: messagesReducer(s.messages, { type: 'SET_RUNNING', running: true }), })); connection.send(pending); + // Reschedule for remaining queued messages + if (parserState.pendingSend.length > 0) { + parserState.pendingSendTimer = setTimeout(drainOne, PENDING_SEND_TIMEOUT_MS); + } else { + parserState.pendingSendTimer = undefined; + } }, PENDING_SEND_TIMEOUT_MS); } else { const sent = connection.send(msg); @@ -696,9 +713,9 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi }> = [], +): EventStoreAdapter { + return { + getEventsAfter: vi.fn((sessionId: string, afterSeq: number, limit?: number) => { + const filtered = events.filter((e) => e.seq > afterSeq); + return limit ? filtered.slice(0, limit) : filtered; + }), + }; +} + describe('ConnectionRegistry', () => { let registry: ConnectionRegistry; @@ -201,4 +212,315 @@ describe('ConnectionRegistry', () => { expect(() => registry.broadcastAll({ type: 'test' })).not.toThrow(); }); }); + + describe('cursor tracking', () => { + it('initializes cursor map when registering a connection', () => { + registry.register('conn-1', mockTransport()); + // Cursor map is private, but we can verify behavior via broadcast + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 10 }); + // No error means cursor map exists + }); + + it('updates cursor on successful broadcast', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'msg1', seq: 5 }); + registry.broadcast('sess-a', { type: 'msg2', seq: 10 }); + + // Cursor should be at 10 now (can't inspect directly, but periodic sync will use it) + expect(t.send).toHaveBeenCalledTimes(2); + }); + + it('does not update cursor when send fails', () => { + const t = mockTransport(true); + (t.send as ReturnType).mockImplementationOnce(() => { + throw new Error('socket closing'); + }); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'failed', seq: 5 }); + // Cursor should stay at 0 (initial state) + + // Now send succeeds + (t.send as ReturnType).mockImplementation(() => {}); + registry.broadcast('sess-a', { type: 'success', seq: 10 }); + // Cursor should jump to 10 + }); + + it('handles out-of-order delivery by only advancing cursor forward', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + registry.broadcast('sess-a', { type: 'msg1', seq: 10 }); + registry.broadcast('sess-a', { type: 'msg2', seq: 5 }); // Out of order + registry.broadcast('sess-a', { type: 'msg3', seq: 15 }); + + // Cursor should be at 15 (max seen), not 5 + expect(t.send).toHaveBeenCalledTimes(3); + }); + + it('cleans up cursors when connection is removed', () => { + registry.register('conn-1', mockTransport()); + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 10 }); + + registry.remove('conn-1'); + // No error on subsequent broadcast means cursor cleanup succeeded + registry.register('conn-1', mockTransport()); + registry.watch('conn-1', 'sess-a'); + registry.broadcast('sess-a', { type: 'test', seq: 20 }); + }); + }); + + describe('resetCursor', () => { + it('resets cursor to client lastSeq on reconnect', () => { + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + // Simulate cursor drift (broadcast moved cursor to 100) + registry.broadcast('sess-a', { type: 'msg', seq: 100 }); + + // Client reconnects with lastSeq=50 (missed 51-100) + registry.resetCursor('conn-1', 'sess-a', 50); + + // Cursor should now be at 50 (verified by periodic sync behavior) + }); + + it('is a no-op for unknown connection', () => { + expect(() => registry.resetCursor('unknown', 'sess-a', 10)).not.toThrow(); + }); + }); + + describe('periodic sync', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries missed events from EventStore', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1', data: 'a' } }, + { seq: 10, payload: { type: 'msg2', data: 'b' } }, + { seq: 15, payload: { type: 'msg3', data: 'c' } }, + ]); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + // Simulate cursor at 0 (never delivered anything) + registry.startPeriodicSync(); + + // Advance time to trigger sync + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch events > 0 from store and deliver them + expect(store.getEventsAfter).toHaveBeenCalledWith('sess-a', 0, 50); + expect(t.send).toHaveBeenCalledTimes(3); + expect(t.send).toHaveBeenCalledWith({ type: 'msg1', data: 'a', seq: 5 }); + expect(t.send).toHaveBeenCalledWith({ type: 'msg2', data: 'b', seq: 10 }); + expect(t.send).toHaveBeenCalledWith({ type: 'msg3', data: 'c', seq: 15 }); + + registry.stopPeriodicSync(); + }); + + it('stops retrying on first send failure in a batch', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + let callCount = 0; + (t.send as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error('socket dead'); + }); + + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1' } }, + { seq: 10, payload: { type: 'msg2' } }, + { seq: 15, payload: { type: 'msg3' } }, + ]); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should send msg1 (success), msg2 (fail), then stop + expect(t.send).toHaveBeenCalledTimes(2); + + registry.stopPeriodicSync(); + }); + + it('skips connections with closed transports', async () => { + vi.useFakeTimers(); + const tClosed = mockTransport(false); + const store = mockEventStore([{ seq: 5, payload: { type: 'test' } }]); + + registry.setEventStore(store); + registry.register('conn-closed', tClosed); + registry.watch('conn-closed', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(tClosed.send).not.toHaveBeenCalled(); + + registry.stopPeriodicSync(); + }); + + it('respects SYNC_BATCH_LIMIT to avoid overwhelming slow clients', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const manyEvents = Array.from({ length: 100 }, (_, i) => ({ + seq: i + 1, + payload: { type: 'msg', i }, + })); + const store = mockEventStore(manyEvents); + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch with limit=50 + expect(store.getEventsAfter).toHaveBeenCalledWith('sess-a', 0, 50); + expect(t.send).toHaveBeenCalledTimes(50); + + registry.stopPeriodicSync(); + }); + + it('handles EventStore fetch errors gracefully', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store: EventStoreAdapter = { + getEventsAfter: vi.fn(() => { + throw new Error('database locked'); + }), + }; + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.startPeriodicSync(); + + // Sync should not throw even when EventStore fetch fails + await expect(vi.advanceTimersByTimeAsync(5000)).resolves.not.toThrow(); + + expect(t.send).not.toHaveBeenCalled(); + + registry.stopPeriodicSync(); + }); + + it('is a no-op when EventStore not set', () => { + const registry2 = new ConnectionRegistry(); + expect(() => registry2.startPeriodicSync()).not.toThrow(); + // No timer started, so no cleanup needed + }); + + it('warns when starting sync twice', () => { + const registry2 = new ConnectionRegistry(); + const store = mockEventStore(); + registry2.setEventStore(store); + registry2.startPeriodicSync(); + // Second call should warn but not crash + expect(() => registry2.startPeriodicSync()).not.toThrow(); + registry2.stopPeriodicSync(); + }); + + it('stops periodic sync and clears timer', () => { + vi.useFakeTimers(); + const store = mockEventStore(); + registry.setEventStore(store); + registry.startPeriodicSync(); + registry.stopPeriodicSync(); + + // Timer should be cleared — no sync fires + const t = mockTransport(true); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + + vi.advanceTimersByTime(5000); + expect(t.send).not.toHaveBeenCalled(); + }); + + it('skips ended sessions when isSessionActive is provided', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([ + { seq: 5, payload: { type: 'msg1' } }, + { seq: 10, payload: { type: 'msg2' } }, + ]); + + // Add isSessionActive — sess-ended is inactive, sess-active is active + (store as EventStoreAdapter & { isSessionActive?: (id: string) => boolean }).isSessionActive = + (id: string) => id !== 'sess-ended'; + + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-ended'); + registry.watch('conn-1', 'sess-active'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should only fetch events for sess-active, not sess-ended + const calls = (store.getEventsAfter as ReturnType).mock.calls; + const sessionIds = calls.map((c: unknown[]) => c[0]); + expect(sessionIds).toContain('sess-active'); + expect(sessionIds).not.toContain('sess-ended'); + + registry.stopPeriodicSync(); + }); + + it('still syncs all sessions when isSessionActive is not provided', async () => { + vi.useFakeTimers(); + const t = mockTransport(true); + const store = mockEventStore([{ seq: 5, payload: { type: 'msg1' } }]); + + // No isSessionActive — backwards compatible + registry.setEventStore(store); + registry.register('conn-1', t); + registry.watch('conn-1', 'sess-a'); + registry.watch('conn-1', 'sess-b'); + registry.startPeriodicSync(); + + await vi.advanceTimersByTimeAsync(5000); + + // Should fetch events for both sessions (no filtering) + const calls = (store.getEventsAfter as ReturnType).mock.calls; + const sessionIds = calls.map((c: unknown[]) => c[0]); + expect(sessionIds).toContain('sess-a'); + expect(sessionIds).toContain('sess-b'); + + registry.stopPeriodicSync(); + }); + }); + + describe('dispose', () => { + it('stops periodic sync and clears all state', () => { + vi.useFakeTimers(); + const store = mockEventStore(); + registry.setEventStore(store); + registry.register('conn-1', mockTransport()); + registry.startPeriodicSync(); + + registry.dispose(); + + expect(registry.get('conn-1')).toBeUndefined(); + + // Timer stopped — no sync fires + vi.advanceTimersByTime(5000); + expect(store.getEventsAfter).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/harness/__tests__/sse-registry.test.ts b/packages/harness/__tests__/sse-registry.test.ts new file mode 100644 index 00000000..97494334 --- /dev/null +++ b/packages/harness/__tests__/sse-registry.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SseRegistry } from '../src/sse-registry.js'; + +function mockResponse() { + return { + write: vi.fn(), + end: vi.fn(), + } as unknown as import('express').Response; +} + +describe('SseRegistry', () => { + let registry: SseRegistry; + + beforeEach(() => { + vi.useFakeTimers(); + registry = new SseRegistry(); + }); + + afterEach(() => { + registry.destroy(); + vi.useRealTimers(); + }); + + it('starts with size 0', () => { + expect(registry.size).toBe(0); + }); + + it('tracks added clients', () => { + registry.add('c1', mockResponse()); + expect(registry.size).toBe(1); + + registry.add('c2', mockResponse()); + expect(registry.size).toBe(2); + }); + + it('removes clients', () => { + registry.add('c1', mockResponse()); + registry.remove('c1'); + expect(registry.size).toBe(0); + }); + + it('remove is a no-op for unknown id', () => { + registry.remove('nonexistent'); + expect(registry.size).toBe(0); + }); + + it('broadcasts to all clients', () => { + const r1 = mockResponse(); + const r2 = mockResponse(); + registry.add('c1', r1); + registry.add('c2', r2); + + registry.broadcast('test_event', { foo: 'bar' }); + + const expected = 'event: test_event\ndata: {"foo":"bar"}\n\n'; + expect(r1.write).toHaveBeenCalledWith(expected); + expect(r2.write).toHaveBeenCalledWith(expected); + }); + + it('skips broadcast when no clients', () => { + // Should not throw + registry.broadcast('test_event', {}); + }); + + it('removes dead clients on broadcast failure', () => { + const r1 = mockResponse(); + const r2 = mockResponse(); + (r1.write as ReturnType).mockImplementation(() => { + throw new Error('connection reset'); + }); + + registry.add('c1', r1); + registry.add('c2', r2); + registry.broadcast('test_event', {}); + + expect(registry.size).toBe(1); + expect(r2.write).toHaveBeenCalled(); + }); + + it('sendTo delivers to a specific client', () => { + const r1 = mockResponse(); + const r2 = mockResponse(); + registry.add('c1', r1); + registry.add('c2', r2); + + const result = registry.sendTo('c1', 'hydrate', [1, 2, 3]); + + expect(result).toBe(true); + expect(r1.write).toHaveBeenCalledWith('event: hydrate\ndata: [1,2,3]\n\n'); + expect(r2.write).not.toHaveBeenCalled(); + }); + + it('sendTo returns false for unknown client', () => { + expect(registry.sendTo('missing', 'test', {})).toBe(false); + }); + + it('sendTo removes client on write failure', () => { + const res = mockResponse(); + (res.write as ReturnType).mockImplementation(() => { + throw new Error('broken'); + }); + registry.add('c1', res); + + const result = registry.sendTo('c1', 'test', {}); + expect(result).toBe(false); + expect(registry.size).toBe(0); + }); + + it('starts heartbeat on first client', () => { + const res = mockResponse(); + registry.add('c1', res); + + vi.advanceTimersByTime(30_000); + expect(res.write).toHaveBeenCalledWith(':heartbeat\n\n'); + }); + + it('stops heartbeat when last client disconnects', () => { + const res = mockResponse(); + registry.add('c1', res); + registry.remove('c1'); + + vi.advanceTimersByTime(30_000); + // Only the initial add, no heartbeat write + expect(res.write).not.toHaveBeenCalled(); + }); + + it('cleans up dead connections during heartbeat', () => { + const r1 = mockResponse(); + const r2 = mockResponse(); + (r1.write as ReturnType).mockImplementation(() => { + throw new Error('dead'); + }); + + registry.add('c1', r1); + registry.add('c2', r2); + + vi.advanceTimersByTime(30_000); + + expect(registry.size).toBe(1); + }); + + it('destroy closes all connections and stops heartbeat', () => { + const r1 = mockResponse(); + const r2 = mockResponse(); + registry.add('c1', r1); + registry.add('c2', r2); + + registry.destroy(); + + expect(r1.end).toHaveBeenCalled(); + expect(r2.end).toHaveBeenCalled(); + expect(registry.size).toBe(0); + + // Heartbeat should be stopped — no writes after destroy + vi.advanceTimersByTime(30_000); + expect(r1.write).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/harness/src/connection-registry.ts b/packages/harness/src/connection-registry.ts index 87dea699..33470ab6 100644 --- a/packages/harness/src/connection-registry.ts +++ b/packages/harness/src/connection-registry.ts @@ -8,6 +8,12 @@ * * Part of the v2 single-WS protocol — replaces the per-session WsPool * model where each session had its own socket. + * + * Delivery Guarantee: + * - Tracks per-connection per-session cursors (last delivered seq) + * - broadcast() updates cursor on successful send + * - Periodic sync retries events beyond cursor (handles WS races, iOS kills) + * - Reconnect resets cursor to client's lastSeq to prevent duplicate replay */ import type { SessionTransport } from './session-transport.js'; @@ -22,8 +28,32 @@ export interface Connection { activeSession: string | null; } +/** Event store interface for periodic sync — injected to avoid circular deps */ +export interface EventStoreAdapter { + getEventsAfter( + sessionId: string, + afterSeq: number, + limit?: number, + ): Array<{ + seq: number; + payload: Record; + }>; + /** Optional: check if a session is still active. When provided, periodic sync + * skips ended sessions to avoid unnecessary EventStore queries. */ + isSessionActive?(sessionId: string): boolean; +} + +// Periodic sync fires every 5s to retry missed events +const SYNC_INTERVAL_MS = 5000; +// Limit events per sync round per connection to avoid overwhelming slow clients +const SYNC_BATCH_LIMIT = 50; + export class ConnectionRegistry { private connections = new Map(); + // Per-connection per-session cursors: last successfully delivered seq + private cursors = new Map>(); + private syncTimer: ReturnType | null = null; + private eventStore: EventStoreAdapter | null = null; register(connectionId: string, transport: SessionTransport): void { this.connections.set(connectionId, { @@ -32,6 +62,8 @@ export class ConnectionRegistry { watchedSessions: new Set(), activeSession: null, }); + // Initialize cursor map for this connection + this.cursors.set(connectionId, new Map()); } get(connectionId: string): Connection | undefined { @@ -40,6 +72,16 @@ export class ConnectionRegistry { remove(connectionId: string): void { this.connections.delete(connectionId); + // Clean up cursors for this connection + this.cursors.delete(connectionId); + } + + /** + * Set the EventStore adapter for periodic sync. + * Must be called before starting periodic sync. + */ + setEventStore(eventStore: EventStoreAdapter): void { + this.eventStore = eventStore; } watch(connectionId: string, sessionId: string): void { @@ -97,14 +139,28 @@ export class ConnectionRegistry { /** * Send a message to all open connections watching a session. * Catches send errors to prevent one failing transport from - * aborting the broadcast loop. + * aborting the broadcast loop. Updates delivery cursor on success + * so periodic sync can retry failures. */ broadcast(sessionId: string, data: Record): void { + const seq = data.seq as number | undefined; for (const { connectionId, transport } of this.getConnectionsWatching(sessionId, true)) { try { transport.send(data); + // Update cursor on successful delivery (if event has seq) + if (seq !== undefined) { + const connCursors = this.cursors.get(connectionId); + if (connCursors) { + const current = connCursors.get(sessionId) ?? 0; + // Only advance cursor forward (handle out-of-order delivery) + if (seq > current) { + connCursors.set(sessionId, seq); + } + } + } } catch { - log.warn('broadcast send failed', { connectionId, sessionId }); + log.warn('broadcast send failed', { connectionId, sessionId, seq }); + // Cursor not updated → periodic sync will retry } } } @@ -125,4 +181,116 @@ export class ConnectionRegistry { } } } + + /** + * Reset the cursor for a connection+session pair to the client's lastSeq. + * Called on reconnect to sync cursor with client state and prevent + * duplicate replay (EventStore handles the gap). + */ + resetCursor(connectionId: string, sessionId: string, clientLastSeq: number): void { + const connCursors = this.cursors.get(connectionId); + if (!connCursors) return; + connCursors.set(sessionId, clientLastSeq); + log.info('cursor reset on reconnect', { connectionId, sessionId, cursor: clientLastSeq }); + } + + /** + * Start periodic sync — retries missed events for all connections. + * Runs every SYNC_INTERVAL_MS, bounded by SYNC_BATCH_LIMIT per connection. + * Call this once during server startup after setEventStore(). + */ + startPeriodicSync(): void { + if (this.syncTimer) { + log.warn('periodic sync already running'); + return; + } + if (!this.eventStore) { + log.error('cannot start periodic sync: EventStore not set'); + return; + } + + log.info('starting periodic sync', { intervalMs: SYNC_INTERVAL_MS }); + + this.syncTimer = setInterval(() => { + if (!this.eventStore) return; + + for (const [connectionId, conn] of this.connections.entries()) { + if (!conn.transport.isOpen()) continue; + + const connCursors = this.cursors.get(connectionId); + if (!connCursors) continue; + + for (const sessionId of conn.watchedSessions) { + // Skip ended sessions to avoid unnecessary EventStore queries + if (this.eventStore.isSessionActive && !this.eventStore.isSessionActive(sessionId)) { + continue; + } + + const cursor = connCursors.get(sessionId) ?? 0; + + // Fetch missed events from EventStore + let missedEvents: Array<{ seq: number; payload: Record }>; + try { + missedEvents = this.eventStore.getEventsAfter(sessionId, cursor, SYNC_BATCH_LIMIT); + } catch (err) { + log.warn('periodic sync: EventStore fetch failed', { + connectionId, + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + + if (missedEvents.length === 0) continue; + + log.info('periodic sync: retrying missed events', { + connectionId, + sessionId, + cursor, + missedCount: missedEvents.length, + }); + + // Retry delivery + for (const evt of missedEvents) { + try { + conn.transport.send({ ...evt.payload, seq: evt.seq }); + // Update cursor on success + const current = connCursors.get(sessionId) ?? 0; + if (evt.seq > current) { + connCursors.set(sessionId, evt.seq); + } + } catch { + // Still failing — stop here, retry next sync round + log.warn('periodic sync: retry failed, stopping batch', { + connectionId, + sessionId, + failedSeq: evt.seq, + }); + break; + } + } + } + } + }, SYNC_INTERVAL_MS); + } + + /** + * Stop periodic sync and clean up timer. Call during graceful shutdown. + */ + stopPeriodicSync(): void { + if (this.syncTimer) { + clearInterval(this.syncTimer); + this.syncTimer = null; + log.info('periodic sync stopped'); + } + } + + /** + * Dispose: stop sync, clear all state. Used for graceful shutdown. + */ + dispose(): void { + this.stopPeriodicSync(); + this.connections.clear(); + this.cursors.clear(); + } } diff --git a/packages/harness/src/constants.ts b/packages/harness/src/constants.ts index d12cd1fb..d30d5004 100644 --- a/packages/harness/src/constants.ts +++ b/packages/harness/src/constants.ts @@ -18,6 +18,8 @@ export const CLOSEOUT_TIMEOUT_MS = 600_000; // 10 minutes max for the agent to f export const PERMISSION_TIMEOUT_MS = 120_000; // 2 minutes export const NTFY_NOTIFICATION_DELAY_MS = 10_000; // 10 seconds +export const USER_CLOSEOUT_TIMEOUT_MS = 120_000; // 2 minutes — user-initiated close gets a shorter timeout + // --- Suspend (proactive iOS backgrounding) --- export const SUSPEND_GRACE_MS = 120_000; // 2 minutes — max time to wait for resume before transitioning to detach export const SUSPEND_BUFFER_MAX = 1000; // max events to buffer per suspended session diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 03614bd7..1df8c13e 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -13,7 +13,11 @@ export type { // Connection registry (v2 single-WS protocol) export { ConnectionRegistry } from './connection-registry.js'; -export type { Connection } from './connection-registry.js'; +export type { Connection, EventStoreAdapter } from './connection-registry.js'; + +// SSE registry (broadcast events) +export { SseRegistry } from './sse-registry.js'; +export type { SseClient } from './sse-registry.js'; // Permissions export { @@ -22,6 +26,7 @@ export { removePending, hasPending, denyPendingBySession, + getPendingCountBySession, } from './permissions.js'; // Tool tiers @@ -56,6 +61,7 @@ export { DETACHED_TTL_MS, CLOSEOUT_LEAD_MS, CLOSEOUT_TIMEOUT_MS, + USER_CLOSEOUT_TIMEOUT_MS, PERMISSION_TIMEOUT_MS, NTFY_NOTIFICATION_DELAY_MS, } from './constants.js'; diff --git a/packages/harness/src/permissions.ts b/packages/harness/src/permissions.ts index c3caef79..4801ff31 100644 --- a/packages/harness/src/permissions.ts +++ b/packages/harness/src/permissions.ts @@ -62,9 +62,19 @@ export function hasPending(permId: string): boolean { return pending.has(permId); } +/** + * Count pending permission requests for a specific session. + */ +export function getPendingCountBySession(sessionId: string): number { + let count = 0; + for (const entry of pending.values()) { + if (entry.sessionId === sessionId) count++; + } + return count; +} + /** * Deny all pending permission requests associated with a session. - * Used during session takeover to clean up stale prompts on the old device. */ export function denyPendingBySession(sessionId: string): number { let denied = 0; diff --git a/packages/harness/src/session-registry.ts b/packages/harness/src/session-registry.ts index c22c79ac..4e394670 100644 --- a/packages/harness/src/session-registry.ts +++ b/packages/harness/src/session-registry.ts @@ -28,7 +28,11 @@ export interface ManagedSession { worktreePath?: string; /** All worktrees created for this session, keyed by repo name. */ worktreePaths: Map; - queryInstance?: { interrupt: () => Promise; close: () => void }; + queryInstance?: { + interrupt: () => Promise; + close: () => void; + stopTask: (taskId: string) => Promise; + }; inputQueue?: { push: (msg: unknown) => void; close: () => void }; currentSnapshot: MessageSnapshot | null; activeSkillPolicy: Set | null; @@ -37,6 +41,9 @@ export interface ManagedSession { cumulativeSessionTokens: number; cumulativeCostUsd: number; taskContext: { currentTaskId: string; goalId: string } | null; + telosTaskId?: string; + /** Active subagent task IDs — task_id → tool_use_id (parent_tool_use_id). */ + activeTaskIds: Map; } export interface ActiveSessionInfo { @@ -60,6 +67,7 @@ export class SessionRegistry { private detachTimers = new Map>(); private closeoutTimers = new Map>(); private closingOut = new Set(); + private userClosing = new Set(); private closeoutHandler: CloseoutHandler | null = null; private suspended = new Set(); private suspendBuffers = new Map[]>(); @@ -75,6 +83,17 @@ export class SessionRegistry { return this.closingOut.has(clientId); } + /** Mark a session as being closed by the user. */ + markUserClose(clientId: string): void { + this.userClosing.add(clientId); + this.closingOut.add(clientId); + } + + /** Check if a session close was user-initiated. */ + isUserClose(clientId: string): boolean { + return this.userClosing.has(clientId); + } + register( clientId: string, init: Omit< @@ -88,6 +107,7 @@ export class SessionRegistry { | 'cumulativeSessionTokens' | 'cumulativeCostUsd' | 'taskContext' + | 'activeTaskIds' > & { sessionId?: string; }, @@ -101,6 +121,7 @@ export class SessionRegistry { cumulativeSessionTokens: 0, cumulativeCostUsd: 0, taskContext: null, + activeTaskIds: new Map(), }); this.attached.add(clientId); } @@ -186,6 +207,7 @@ export class SessionRegistry { this.clearDetachTimer(clientId); this.clearCloseoutTimer(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); this.clearSuspendState(clientId); return true; } @@ -293,6 +315,7 @@ export class SessionRegistry { this.sessions.delete(clientId); this.attached.delete(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); } /** @@ -308,6 +331,7 @@ export class SessionRegistry { this.sessions.delete(clientId); this.attached.delete(clientId); this.closingOut.delete(clientId); + this.userClosing.delete(clientId); } /** @@ -324,6 +348,7 @@ export class SessionRegistry { } this.closeoutTimers.clear(); this.closingOut.clear(); + this.userClosing.clear(); for (const timer of this.suspendTimers.values()) { clearTimeout(timer); diff --git a/packages/harness/src/sse-registry.ts b/packages/harness/src/sse-registry.ts new file mode 100644 index 00000000..763cae45 --- /dev/null +++ b/packages/harness/src/sse-registry.ts @@ -0,0 +1,129 @@ +import type { Response } from 'express'; +import { createLogger } from './logger.js'; + +const log = createLogger('sse'); + +const HEARTBEAT_INTERVAL_MS = 30_000; // 30s — keeps connection alive through proxies + +export interface SseClient { + id: string; + res: Response; +} + +export class SseRegistry { + private clients = new Map(); + private heartbeatTimer: ReturnType | null = null; + + add(id: string, res: Response): void { + this.clients.set(id, { id, res }); + log.info('SSE client connected', { id, total: this.clients.size }); + + if (this.clients.size === 1) { + this.startHeartbeat(); + } + } + + remove(id: string): void { + if (!this.clients.has(id)) return; + + this.clients.delete(id); + log.info('SSE client disconnected', { id, total: this.clients.size }); + + if (this.clients.size === 0) { + this.stopHeartbeat(); + } + } + + broadcast(event: string, data: unknown): void { + if (this.clients.size === 0) return; + + const payload = this.formatEvent(event, data); + let sent = 0; + const failures: string[] = []; + + for (const [id, client] of this.clients) { + try { + client.res.write(payload); + sent++; + } catch (err) { + failures.push(id); + log.warn('SSE broadcast write failed', { id, event, error: String(err) }); + } + } + + log.debug('SSE broadcast', { event, sent, failures: failures.length }); + + // Clean up dead connections + for (const id of failures) { + this.remove(id); + } + } + + sendTo(clientId: string, event: string, data: unknown): boolean { + const client = this.clients.get(clientId); + if (!client) return false; + + const payload = this.formatEvent(event, data); + try { + client.res.write(payload); + return true; + } catch (err) { + log.warn('SSE sendTo failed', { clientId, event, error: String(err) }); + this.remove(clientId); + return false; + } + } + + get size(): number { + return this.clients.size; + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private formatEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + } + + private startHeartbeat(): void { + if (this.heartbeatTimer) return; + + log.info('Starting SSE heartbeat', { intervalMs: HEARTBEAT_INTERVAL_MS }); + this.heartbeatTimer = setInterval(() => { + const payload = ':heartbeat\n\n'; // SSE comment line — ignored by EventSource + const failures: string[] = []; + for (const [, client] of this.clients) { + try { + client.res.write(payload); + } catch { + failures.push(client.id); + } + } + for (const id of failures) { + this.remove(id); + } + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (!this.heartbeatTimer) return; + + log.info('Stopping SSE heartbeat'); + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + destroy(): void { + log.info('Destroying SSE registry', { clients: this.clients.size }); + this.stopHeartbeat(); + + for (const [, client] of this.clients) { + try { + client.res.end(); + } catch { + // Best effort + } + } + + this.clients.clear(); + } +} diff --git a/packages/protocol/__tests__/event-store.test.ts b/packages/protocol/__tests__/event-store.test.ts index 5acd9576..dce6e44d 100644 --- a/packages/protocol/__tests__/event-store.test.ts +++ b/packages/protocol/__tests__/event-store.test.ts @@ -165,6 +165,30 @@ describe('EventStore', () => { expect(session!.goalId).toBe('goal-abc-123'); }); + it('persists telosTaskId on insert', () => { + store.upsertSession({ sessionId: 'sess-1', summary: 'Test', telosTaskId: 'telos-abc' }); + + const session = store.getSession('sess-1'); + expect(session).not.toBeNull(); + expect(session!.telosTaskId).toBe('telos-abc'); + }); + + it('persists and retrieves telosTaskId via update', () => { + store.upsertSession({ sessionId: 'sess-1', summary: 'Test' }); + store.upsertSession({ sessionId: 'sess-1', telosTaskId: 'telos-xyz' }); + + const session = store.getSession('sess-1'); + expect(session).not.toBeNull(); + expect(session!.telosTaskId).toBe('telos-xyz'); + }); + + it('defaults telosTaskId to null when not provided', () => { + store.upsertSession({ sessionId: 'sess-1', summary: 'Test' }); + + const session = store.getSession('sess-1'); + expect(session!.telosTaskId).toBeNull(); + }); + it('preserves fields not included in partial update', () => { store.upsertSession({ sessionId: 'sess-1', diff --git a/packages/protocol/__tests__/subagent-types.test.ts b/packages/protocol/__tests__/subagent-types.test.ts new file mode 100644 index 00000000..609058f2 --- /dev/null +++ b/packages/protocol/__tests__/subagent-types.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import type { StreamingBlock, FinishedBlock, SubagentUsage, SubagentState } from '../src/types.js'; + +describe('Subagent Protocol Types', () => { + it('StreamingBlock supports nested subagent state', () => { + const block: StreamingBlock = { + blockId: 'b1', + blockType: 'tool_use', + content: '', + done: false, + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: new Map(), + blockOrder: [], + running: true, + }, + }; + + expect(block.subagent).toBeDefined(); + expect(block.subagent?.running).toBe(true); + expect(block.subagent?.blocks).toBeInstanceOf(Map); + }); + + it('FinishedBlock supports nested subagent state', () => { + const subBlock: FinishedBlock = { + blockId: 'b-sub-1', + blockType: 'text', + content: 'Subagent output', + }; + + const block: FinishedBlock = { + blockId: 'b1', + blockType: 'tool_use', + content: '', + toolName: 'Agent', + toolId: 't1', + subagent: { + messageId: 'msg-sub-1', + blocks: [subBlock], + summary: 'Completed search', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }, + }; + + expect(block.subagent).toBeDefined(); + expect(block.subagent?.blocks).toHaveLength(1); + expect(block.subagent?.summary).toBe('Completed search'); + expect(block.subagent?.usage?.inputTokens).toBe(100); + }); + + it('SubagentUsage type has required token fields', () => { + const usage: SubagentUsage = { + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 50, + cacheCreationTokens: 25, + }; + + expect(usage.inputTokens).toBe(200); + expect(usage.outputTokens).toBe(100); + expect(usage.cacheReadTokens).toBe(50); + expect(usage.cacheCreationTokens).toBe(25); + }); + + it('SubagentState supports streaming mode', () => { + const state: SubagentState = { + messageId: 'msg-sub-1', + blocks: new Map([ + [ + 'b1', + { + blockId: 'b1', + blockType: 'thinking', + content: 'Analyzing...', + done: false, + }, + ], + ]), + blockOrder: ['b1'], + running: true, + }; + + expect(state.running).toBe(true); + expect(state.blocks.size).toBe(1); + expect(state.blockOrder).toEqual(['b1']); + }); + + it('SubagentState supports finished mode', () => { + const state: SubagentState = { + messageId: 'msg-sub-1', + blocks: [ + { + blockId: 'b1', + blockType: 'text', + content: 'Done', + }, + ], + summary: 'Task complete', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }; + + expect(Array.isArray(state.blocks)).toBe(true); + expect(state.running).toBeUndefined(); + expect(state.summary).toBe('Task complete'); + }); +}); diff --git a/packages/protocol/__tests__/ws-schemas-v2.test.ts b/packages/protocol/__tests__/ws-schemas-v2.test.ts index 4b426211..ce0d6003 100644 --- a/packages/protocol/__tests__/ws-schemas-v2.test.ts +++ b/packages/protocol/__tests__/ws-schemas-v2.test.ts @@ -106,6 +106,20 @@ describe('v2 send', () => { }); expect(r.success).toBe(false); }); + + it('accepts send with telosTaskId', () => { + const r = V2SendMessage.safeParse({ + type: 'send', + sessionId: null, + prompt: 'hello', + clientMsgId: 'u-1', + telosTaskId: 'abc123def456', + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.telosTaskId).toBe('abc123def456'); + } + }); }); describe('v2 interrupt / stop / permission_response / set_mode', () => { diff --git a/packages/protocol/src/event-store.ts b/packages/protocol/src/event-store.ts index 14070867..9200d7af 100644 --- a/packages/protocol/src/event-store.ts +++ b/packages/protocol/src/event-store.ts @@ -41,6 +41,8 @@ interface SessionRow { duration_ms: number; duration_api_ms: number; goal_id: string | null; + telos_task_id: string | null; + closed_by: string | null; created_at: number; updated_at: number; } @@ -98,6 +100,7 @@ export class EventStore { this.migratePromptTracking(db); this.migrateUsageTracking(db); this.migrateWorktreeTracking(db); + this.migrateCloseTracking(db); this.log.info('EventStore initialized', { dbPath }); @@ -180,6 +183,7 @@ export class EventStore { 'ALTER TABLE sessions ADD COLUMN duration_api_ms INTEGER NOT NULL DEFAULT 0', ], ['goal_id', 'ALTER TABLE sessions ADD COLUMN goal_id TEXT'], + ['telos_task_id', 'ALTER TABLE sessions ADD COLUMN telos_task_id TEXT'], ]; for (const [col, sql] of migrations) { if (!columnNames.has(col)) { @@ -198,6 +202,15 @@ export class EventStore { } } + private migrateCloseTracking(db: Database.Database): void { + const columns = db.prepare("PRAGMA table_info('sessions')").all() as Array<{ name: string }>; + const columnNames = new Set(columns.map((c) => c.name)); + if (!columnNames.has('closed_by')) { + db.exec('ALTER TABLE sessions ADD COLUMN closed_by TEXT'); + this.log.info('migrated sessions table: added closed_by'); + } + } + close(): void { if (this.db) { this.db.close(); @@ -256,10 +269,18 @@ export class EventStore { fields.push('goal_id = ?'); values.push(meta.goalId); } + if (meta.telosTaskId !== undefined) { + fields.push('telos_task_id = ?'); + values.push(meta.telosTaskId); + } if (meta.wtId !== undefined) { fields.push('wt_id = ?'); values.push(meta.wtId); } + if (meta.closedBy !== undefined) { + fields.push('closed_by = ?'); + values.push(meta.closedBy); + } if (meta.updatedAt !== undefined) { fields.push('updated_at = ?'); values.push(meta.updatedAt); @@ -281,6 +302,8 @@ export class EventStore { 'initial_prompt', 'wt_id', 'goal_id', + 'telos_task_id', + 'closed_by', ]; const vals: unknown[] = [ meta.sessionId, @@ -292,6 +315,8 @@ export class EventStore { meta.initialPrompt ?? null, meta.wtId ?? null, meta.goalId ?? null, + meta.telosTaskId ?? null, + meta.closedBy ?? null, ]; if (meta.updatedAt !== undefined) { cols.push('updated_at'); @@ -504,6 +529,8 @@ function rowToSession(row: SessionRow): SessionMeta { durationMs: row.duration_ms ?? 0, durationApiMs: row.duration_api_ms ?? 0, goalId: row.goal_id ?? null, + telosTaskId: row.telos_task_id ?? null, + closedBy: (row.closed_by as SessionMeta['closedBy']) ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 21cc72f8..98ab8d02 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -15,6 +15,7 @@ export type { PermissionRequest, ImageAttachment, Session, + SessionClosedBy, StoredEvent, SessionMeta, SessionSearchResult, @@ -22,6 +23,15 @@ export type { ProgressItemStatus, ProgressItem, ProgressBlock, + SessionActivityState, + WaitReason, + SessionActivity, + ServiceHealthStatus, + ServiceHealthPayload, + StreamingSubagentState, + FinishedSubagentState, + SubagentState, + SubagentUsage, } from './types.js'; // Constants @@ -66,6 +76,7 @@ export { UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2InterruptMessage, V2StopMessage, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 85c824b7..0a31f106 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -50,6 +50,7 @@ export interface StreamingBlock { rawInput?: RawToolInput; toolResult?: string; toolError?: boolean; + subagent?: StreamingSubagentState | FinishedSubagentState; } export interface StreamingMessage { @@ -70,6 +71,7 @@ export interface FinishedBlock { rawInput?: RawToolInput; toolResult?: string; toolError?: boolean; + subagent?: FinishedSubagentState; } export interface FinishedMessage { @@ -103,6 +105,8 @@ export interface ImageAttachment { // --- Session (client-facing) --- +export type SessionClosedBy = 'user' | 'auto' | 'abandoned'; + export interface Session { id: string; summary: string; @@ -112,6 +116,40 @@ export interface Session { isAttached?: boolean; totalTokens?: number; numTurns?: number; + telosTaskId?: string; + closedBy?: SessionClosedBy; +} + +// --- Session activity types (SSE event bus) --- + +export type SessionActivityState = 'init' | 'working' | 'waiting' | 'done' | 'idle' | 'paused'; + +export type WaitReason = 'permission' | 'review' | 'blocked'; + +export interface SessionActivity { + sessionId: string; + clientId: string; + title: string; + repo?: string; + state: SessionActivityState; + flags: SessionActivityState[]; + waitReason?: WaitReason; + progress?: { done: number; total: number }; + lastEventAt: number; + taskId?: string; +} + +// --- Service health (SSE event bus) --- + +export interface ServiceHealthStatus { + name: string; + ok: boolean; + detail?: Record; +} + +export interface ServiceHealthPayload { + services: ServiceHealthStatus[]; + checkedAt: number; } // --- Event store types --- @@ -158,6 +196,8 @@ export interface SessionMeta { durationMs: number; durationApiMs: number; goalId: string | null; + telosTaskId: string | null; + closedBy: SessionClosedBy | null; createdAt: number; updatedAt: number; } @@ -177,3 +217,34 @@ export interface ProgressBlock { items: ProgressItem[]; sourceToolId?: string; } + +// --- Subagent nesting (nested agent execution visibility) --- + +export interface SubagentUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +/** Streaming subagent state — blocks in a Map, running flag set. */ +export interface StreamingSubagentState { + messageId: string; + blocks: Map; + blockOrder: string[]; + running: true; + summary?: never; + usage?: never; +} + +/** Finished subagent state — blocks as array, summary + usage set. */ +export interface FinishedSubagentState { + messageId: string; + blocks: FinishedBlock[]; + summary?: string; + usage?: SubagentUsage; + running?: never; +} + +/** Union type for subagent state — streaming or finished. */ +export type SubagentState = StreamingSubagentState | FinishedSubagentState; diff --git a/packages/protocol/src/ws-schemas-v2.ts b/packages/protocol/src/ws-schemas-v2.ts index 1f072e09..7e30f945 100644 --- a/packages/protocol/src/ws-schemas-v2.ts +++ b/packages/protocol/src/ws-schemas-v2.ts @@ -65,6 +65,11 @@ export const SessionSuspendMessage = z.object({ ), }); +export const SessionCloseMessage = z.object({ + type: z.literal('session_close'), + sessionId: z.string().min(1), +}); + // ─── Chat messages (session-scoped) ───────────────────────────────────────── // sessionId is nullable on send (null = start new session) but required on @@ -82,6 +87,7 @@ export const V2SendMessage = z.object({ isolation: z.boolean().optional(), images: z.array(ImageSchema).optional(), contextBlocks: z.array(z.string()).optional(), + telosTaskId: z.string().optional(), }); export const V2InterruptMessage = z.object({ @@ -120,6 +126,7 @@ export const IncomingWsMessageV2 = z.discriminatedUnion('type', [ UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2InterruptMessage, V2StopMessage, diff --git a/server/__tests__/health-monitor.test.ts b/server/__tests__/health-monitor.test.ts new file mode 100644 index 00000000..0e87f7f8 --- /dev/null +++ b/server/__tests__/health-monitor.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HealthMonitor } from '../health-monitor'; + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock SseRegistry +function createMockRegistry() { + return { + broadcast: vi.fn(), + sendTo: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + size: 0, + destroy: vi.fn(), + }; +} + +function yapperOk(stt = true, tts = true) { + return { + ok: true, + json: () => Promise.resolve({ status: 'ready', models: { stt, tts } }), + }; +} + +function contexginOk() { + return { ok: true, json: () => Promise.resolve({ status: 'healthy' }) }; +} + +function serviceDown() { + return { ok: false, json: () => Promise.resolve({}) }; +} + +describe('HealthMonitor', () => { + let registry: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + registry = createMockRegistry(); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('broadcasts health on first check', async () => { + mockFetch.mockResolvedValueOnce(yapperOk()).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + + // Flush the initial async check + await vi.advanceTimersByTimeAsync(0); + + expect(registry.broadcast).toHaveBeenCalledWith( + 'health', + expect.objectContaining({ + services: expect.arrayContaining([ + expect.objectContaining({ name: 'yapper', ok: true }), + expect.objectContaining({ name: 'contexgin', ok: true }), + ]), + checkedAt: expect.any(Number), + }), + ); + + monitor.destroy(); + }); + + it('does not broadcast when status is unchanged', async () => { + mockFetch + .mockResolvedValueOnce(yapperOk()) + .mockResolvedValueOnce(contexginOk()) + .mockResolvedValueOnce(yapperOk()) + .mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(registry.broadcast).toHaveBeenCalledTimes(1); + + // Advance to next poll + await vi.advanceTimersByTimeAsync(30_000); + // No second broadcast — status unchanged + expect(registry.broadcast).toHaveBeenCalledTimes(1); + + monitor.destroy(); + }); + + it('broadcasts when status changes', async () => { + // First check: both ok + mockFetch.mockResolvedValueOnce(yapperOk()).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(registry.broadcast).toHaveBeenCalledTimes(1); + + // Second check: yapper down + mockFetch.mockResolvedValueOnce(serviceDown()).mockResolvedValueOnce(contexginOk()); + await vi.advanceTimersByTimeAsync(30_000); + expect(registry.broadcast).toHaveBeenCalledTimes(2); + + const lastCall = registry.broadcast.mock.calls[1]; + expect(lastCall[1].services[0]).toEqual(expect.objectContaining({ name: 'yapper', ok: false })); + + monitor.destroy(); + }); + + it('parses Yapper detail with stt/tts fields', async () => { + mockFetch.mockResolvedValueOnce(yapperOk(true, false)).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + const payload = registry.broadcast.mock.calls[0][1]; + const yapper = payload.services.find((s: any) => s.name === 'yapper'); + expect(yapper.detail).toEqual({ stt: true, tts: false }); + + monitor.destroy(); + }); + + it('returns undefined detail when Yapper omits models', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: 'ok' }), + }) + .mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + const payload = registry.broadcast.mock.calls[0][1]; + const yapper = payload.services.find((s: any) => s.name === 'yapper'); + expect(yapper.detail).toBeUndefined(); + + monitor.destroy(); + }); + + it('marks service as down on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + const payload = registry.broadcast.mock.calls[0][1]; + const yapper = payload.services.find((s: any) => s.name === 'yapper'); + expect(yapper.ok).toBe(false); + + monitor.destroy(); + }); + + it('getSnapshot returns last payload', async () => { + mockFetch.mockResolvedValueOnce(yapperOk()).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + // Before start — empty snapshot + expect(monitor.getSnapshot()).toEqual({ services: [], checkedAt: 0 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + const snapshot = monitor.getSnapshot(); + expect(snapshot.services).toHaveLength(2); + expect(snapshot.checkedAt).toBeGreaterThan(0); + + monitor.destroy(); + }); + + it('broadcasts on detail change even if ok is same', async () => { + // First: stt=true, tts=true + mockFetch.mockResolvedValueOnce(yapperOk(true, true)).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(registry.broadcast).toHaveBeenCalledTimes(1); + + // Second: stt=true, tts=false — ok is still true but detail changed + mockFetch.mockResolvedValueOnce(yapperOk(true, false)).mockResolvedValueOnce(contexginOk()); + await vi.advanceTimersByTimeAsync(30_000); + expect(registry.broadcast).toHaveBeenCalledTimes(2); + + monitor.destroy(); + }); + + it('marks service as down on fetch timeout', async () => { + // Simulate AbortSignal.timeout rejecting with TimeoutError + const timeoutErr = new DOMException('signal timed out', 'TimeoutError'); + mockFetch.mockRejectedValueOnce(timeoutErr).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + const payload = registry.broadcast.mock.calls[0]?.[1]; + const yapper = payload?.services.find((s: any) => s.name === 'yapper'); + expect(yapper?.ok).toBe(false); + + monitor.destroy(); + }); + + it('start() is idempotent — calling twice does not create duplicate intervals', async () => { + mockFetch.mockResolvedValue(yapperOk()).mockResolvedValue(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + monitor.start(); // second call should be a no-op + + await vi.advanceTimersByTimeAsync(0); + + // Only one initial check, not two + // Each check fetches 2 services, so 2 fetch calls = 1 check + expect(mockFetch).toHaveBeenCalledTimes(2); + + monitor.destroy(); + }); + + it('destroy clears the timer', async () => { + mockFetch.mockResolvedValueOnce(yapperOk()).mockResolvedValueOnce(contexginOk()); + + const monitor = new HealthMonitor(registry as any); + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + monitor.destroy(); + + // Advance time — no further checks + mockFetch.mockReset(); + await vi.advanceTimersByTimeAsync(60_000); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/server/__tests__/query-loop.test.ts b/server/__tests__/query-loop.test.ts index 440c46a2..c0e64a15 100644 --- a/server/__tests__/query-loop.test.ts +++ b/server/__tests__/query-loop.test.ts @@ -4,6 +4,73 @@ import { ConnectionRegistry } from '../../packages/harness/src/connection-regist import { runQueryLoop } from '../query-loop.js'; import type { SessionRegistry } from '../session-registry.js'; import { EventStore } from '../event-store.js'; +import type { Span as OTelSpan } from '@opentelemetry/api'; + +/** Lightweight in-memory span for testing OTel instrumentation. */ +interface RecordedEvent { + name: string; + attributes?: Record; +} +interface TestSpan { + name: string; + attributes: Record; + events: RecordedEvent[]; + status: { code: number; message?: string }; + ended: boolean; +} + +function createTestSpan(name: string): TestSpan & OTelSpan { + const span: TestSpan = { name, attributes: {}, events: [], status: { code: 0 }, ended: false }; + return { + ...span, + setAttribute(k: string, v: unknown) { + span.attributes[k] = v; + return this; + }, + setAttributes(attrs: Record) { + Object.assign(span.attributes, attrs); + return this; + }, + addEvent(eName: string, attrs?: Record) { + span.events.push({ name: eName, attributes: attrs }); + return this; + }, + setStatus(s: { code: number; message?: string }) { + span.status = s; + return this; + }, + end() { + span.ended = true; + }, + updateName(n: string) { + span.name = n; + return this; + }, + isRecording: () => true, + recordException: () => {}, + spanContext: () => ({ traceId: 'test', spanId: 'test', traceFlags: 1 }), + // Expose internals for assertions + get _events() { + return span.events; + }, + get _name() { + return span.name; + }, + } as unknown as TestSpan & OTelSpan; +} + +/** Collects all spans created by the mocked tracer. */ +const recordedSpans: (TestSpan & OTelSpan)[] = []; + +vi.mock('../tracing.js', () => ({ + tracer: { + startSpan(name: string) { + const s = createTestSpan(name); + recordedSpans.push(s); + return s; + }, + }, +})); /** Create a fake SessionTransport that records sent messages */ function fakeTransport(): SessionTransport & { sent: Record[] } { @@ -987,6 +1054,78 @@ describe('runQueryLoop', () => { expect(userMsgs[0].payload).toMatchObject({ text: 'Follow-up question' }); }); + it('flushes pre-sessionId events to store when sessionId resolves (new session)', async () => { + // Simulate a brand-new session: no sessionId pre-set on the registry. + // Events emitted before the `assistant` completion should be buffered + // and retroactively persisted once the sessionId is known. + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-flush' } } }, + { + type: 'stream_event', + event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'buffered content' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-flush' }, + { type: 'result', session_id: 'sess-flush' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController, store); + + const stored = store.getSessionEvents('sess-flush'); + // All event types should be present — including those emitted before sessionId + expect(stored.some((e) => e.type === 'message_start')).toBe(true); + expect(stored.some((e) => e.type === 'block_start')).toBe(true); + expect(stored.some((e) => e.type === 'block_delta')).toBe(true); + expect(stored.some((e) => e.type === 'block_end')).toBe(true); + expect(stored.some((e) => e.type === 'message_end')).toBe(true); + }); + + it('persists events immediately when sessionId is pre-set (resumed session)', async () => { + // Simulate a resumed session: sessionId is known before the query loop + // starts (set by startChat passing options.resume to registry.register). + const session = registry.get(clientId)!; + session.sessionId = 'sess-resume-persist'; + + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-rp' } } }, + { + type: 'stream_event', + event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'resumed content' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-resume-persist' }, + { type: 'result', session_id: 'sess-resume-persist' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController, store); + + const stored = store.getSessionEvents('sess-resume-persist'); + expect(stored.some((e) => e.type === 'message_start')).toBe(true); + expect(stored.some((e) => e.type === 'block_delta')).toBe(true); + expect(stored.some((e) => e.type === 'message_end')).toBe(true); + + // All v2 events should have the sessionId tag + for (const e of stored) { + expect(e.payload.sessionId).toBe('sess-resume-persist'); + } + }); + it('works without a store (backward compatible)', async () => { const events: Record[] = [ { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-nostore' } } }, @@ -1405,6 +1544,34 @@ describe('runQueryLoop', () => { expect(v2Transport.sent.some((m) => m.type === 'session_end')).toBe(true); }); + it('persists error-path session_end to EventStore via sendOrBuffer', async () => { + const store = new EventStore(':memory:'); + const connRegistry = new ConnectionRegistry(); + const v2Transport = fakeTransport(); + connRegistry.register(clientId, v2Transport); + connRegistry.watch(clientId, 'sess-err'); + connRegistry.setActive(clientId, 'sess-err'); + + const session = registry.get(clientId)!; + session.sessionId = 'sess-err'; + + async function* errorStream() { + yield { + type: 'stream_event', + event: { type: 'message_start', message: { id: 'msg-err' } }, + }; + throw new Error('simulated failure'); + } + + await runQueryLoop(errorStream(), clientId, registry, abortController, store, 'sess-err', { + connRegistry, + }); + + // session_end must be persisted to EventStore for reconnect replay + const events = store.getEventsAfter('sess-err', 0); + expect(events.some((e) => e.type === 'session_end')).toBe(true); + }); + it('delivers events to a NEW connection after WS reconnect (old connection gone)', async () => { const connRegistry = new ConnectionRegistry(); const oldTransport = fakeTransport(); @@ -1700,4 +1867,256 @@ describe('runQueryLoop', () => { vi.useRealTimers(); }); }); + + describe('observability — span events for agent output', () => { + beforeEach(() => { + recordedSpans.length = 0; + }); + + it('records block.text span event on turn span for text blocks', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-t1' } } }, + { + type: 'stream_event', + event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello world' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-text' }, + { type: 'result', session_id: 'sess-text' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const turnSpan = recordedSpans.find((s) => s.name === 'turn'); + expect(turnSpan).toBeDefined(); + const textEvent = turnSpan!.events.find((e) => e.name === 'block.text'); + expect(textEvent).toBeDefined(); + expect(textEvent!.attributes!['block.content']).toBe('Hello world'); + expect(textEvent!.attributes!['block.truncated']).toBeUndefined(); + }); + + it('records block.thinking span event on turn span for thinking blocks', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-th' } } }, + { + type: 'stream_event', + event: { type: 'content_block_start', index: 0, content_block: { type: 'thinking' } }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me reason...' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-think' }, + { type: 'result', session_id: 'sess-think' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const turnSpan = recordedSpans.find((s) => s.name === 'turn'); + expect(turnSpan).toBeDefined(); + const thinkEvent = turnSpan!.events.find((e) => e.name === 'block.thinking'); + expect(thinkEvent).toBeDefined(); + expect(thinkEvent!.attributes!['block.content']).toBe('Let me reason...'); + }); + + it('records tool.input span event on tool span', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-ti' } } }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', name: 'Read', id: 'tool-1' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"file_path":"/tmp/test.ts"}' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-tool' }, + { type: 'result', session_id: 'sess-tool' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const toolSpan = recordedSpans.find((s) => s.name === 'tool.Read'); + expect(toolSpan).toBeDefined(); + const inputEvent = toolSpan!.events.find((e) => e.name === 'tool.input'); + expect(inputEvent).toBeDefined(); + expect(inputEvent!.attributes!['tool.name']).toBe('Read'); + expect(inputEvent!.attributes!['tool.input']).toBe('{"file_path":"/tmp/test.ts"}'); + }); + + it('records tool.result span event on session span', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-tr' } } }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', name: 'Read', id: 'tool-2' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-tr' }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-2', + content: 'file contents here', + is_error: false, + }, + ], + }, + }, + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-tr2' } } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-tr' }, + { type: 'result', session_id: 'sess-tr' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const sessionSpan = recordedSpans.find((s) => s.name === 'session'); + expect(sessionSpan).toBeDefined(); + const resultEvent = sessionSpan!.events.find((e) => e.name === 'tool.result'); + expect(resultEvent).toBeDefined(); + expect(resultEvent!.attributes!['tool.id']).toBe('tool-2'); + expect(resultEvent!.attributes!['tool.result']).toBe('file contents here'); + expect(resultEvent!.attributes!['tool.is_error']).toBe(false); + }); + }); + + describe('subagent block type tracking', () => { + it('emits correct blockType for thinking blocks in subagent_block_end', async () => { + const events: Record[] = [ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-sa1' } } }, + // Parent tool_use block (Agent tool) + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', name: 'Agent', id: 'agent-tool-1' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"prompt":"test"}' }, + }, + }, + // Subagent message_start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'message_start', message: { id: 'sub-msg-1' } }, + }, + // Subagent thinking block start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking' }, + }, + }, + // Subagent thinking delta + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }, + }, + // Subagent thinking block stop — should produce blockType: 'thinking', not 'text' + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'content_block_stop', index: 0 }, + }, + // Subagent text block start + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_start', + index: 1, + content_block: { type: 'text' }, + }, + }, + // Subagent text delta + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Response' }, + }, + }, + // Subagent text block stop — should produce blockType: 'text' + { + type: 'stream_event', + parent_tool_use_id: 'agent-tool-1', + event: { type: 'content_block_stop', index: 1 }, + }, + // End parent tool_use block + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + { type: 'assistant', message: { content: [] }, session_id: 'sess-sa' }, + { type: 'result', session_id: 'sess-sa' }, + ]; + + await runQueryLoop(eventStream(events), clientId, registry, abortController); + + const sent = transport.sent; + + // Find the subagent_block_end events + const subagentBlockEnds = sent.filter((m) => m.type === 'subagent_block_end'); + expect(subagentBlockEnds.length).toBeGreaterThanOrEqual(2); + + // First subagent_block_end should be thinking (index 0 was a thinking block) + const thinkingEnd = subagentBlockEnds.find((m) => m.blockType === 'thinking'); + expect(thinkingEnd).toBeDefined(); + + // Second subagent_block_end should be text (index 1 was a text block) + const textEnd = subagentBlockEnds.find((m) => m.blockType === 'text'); + expect(textEnd).toBeDefined(); + }); + }); }); diff --git a/server/__tests__/reconstruct-messages.test.ts b/server/__tests__/reconstruct-messages.test.ts index 93419281..5c27a694 100644 --- a/server/__tests__/reconstruct-messages.test.ts +++ b/server/__tests__/reconstruct-messages.test.ts @@ -290,4 +290,33 @@ describe('replayEventsToMessages — user_message events', () => { blocks: [{ content: 'Hello from empty session' }], }); }); + + it('reuses stored messageId for initial prompt (deterministic across calls)', () => { + const events: StoredEvent[] = [ + evt(1, 'user_message', { messageId: 'umsg-12345-init', text: 'Hello Claude' }), + evt(2, 'message_start', { messageId: 'msg-a1' }), + evt(3, 'block_start', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(4, 'block_delta', { messageId: 'msg-a1', blockId: 'b0', delta: 'Hi!' }), + evt(5, 'block_end', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(6, 'message_end', { messageId: 'msg-a1' }), + ]; + const result1 = replayEventsToMessages(events, 'Hello Claude'); + const result2 = replayEventsToMessages(events, 'Hello Claude'); + expect(result1[0].messageId).toBe('umsg-12345-init'); + expect(result2[0].messageId).toBe('umsg-12345-init'); + // Same ID across calls — no Date.now() instability + expect(result1[0].messageId).toBe(result2[0].messageId); + }); + + it('falls back to stable umsg-initial when no matching event exists', () => { + const events: StoredEvent[] = [ + evt(1, 'message_start', { messageId: 'msg-a1' }), + evt(2, 'block_start', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(3, 'block_delta', { messageId: 'msg-a1', blockId: 'b0', delta: 'Hi!' }), + evt(4, 'block_end', { messageId: 'msg-a1', blockId: 'b0', blockType: 'text' }), + evt(5, 'message_end', { messageId: 'msg-a1' }), + ]; + const result = replayEventsToMessages(events, 'Hello Claude'); + expect(result[0].messageId).toBe('umsg-initial'); + }); }); diff --git a/server/__tests__/send-to-chat.test.ts b/server/__tests__/send-to-chat.test.ts index 1c4e04b2..558a87de 100644 --- a/server/__tests__/send-to-chat.test.ts +++ b/server/__tests__/send-to-chat.test.ts @@ -136,7 +136,11 @@ describe('interruptChat emits user_message via transport', () => { const session = registry.get(CLIENT_ID)!; session.sessionId = 'sess-int-1'; session.inputQueue = { push: pushSpy, close: vi.fn() }; - session.queryInstance = { interrupt: vi.fn().mockResolvedValue(undefined), close: vi.fn() }; + session.queryInstance = { + interrupt: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + stopTask: vi.fn().mockResolvedValue(undefined), + }; const result = await interruptChat(CLIENT_ID, 'Urgent message'); expect(result).toBe(true); @@ -165,7 +169,11 @@ describe('interruptChat emits user_message via transport', () => { const session = registry.get(CLIENT_ID)!; session.sessionId = 'sess-int-2'; session.inputQueue = { push: pushSpy, close: vi.fn() }; - session.queryInstance = { interrupt: vi.fn().mockResolvedValue(undefined), close: vi.fn() }; + session.queryInstance = { + interrupt: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + stopTask: vi.fn().mockResolvedValue(undefined), + }; const result = await interruptChat(CLIENT_ID, 'Urgent', undefined, undefined, 'user-5678-def'); expect(result).toBe(true); @@ -176,4 +184,36 @@ describe('interruptChat emits user_message via transport', () => { expect(userMsgEvents).toHaveLength(1); expect((userMsgEvents[0] as Record).messageId).toBe('user-5678-def'); }); + + it('calls stopTask for active subagent tasks before interrupt', async () => { + const transport = mockTransport(); + const pushSpy = vi.fn(); + const stopTaskSpy = vi.fn().mockResolvedValue(undefined); + const interruptSpy = vi.fn().mockResolvedValue(undefined); + + registry.register(CLIENT_ID, { + transport, + abortController: new AbortController(), + mode: 'agent', + sessionAllowList: new Set(), + }); + + const session = registry.get(CLIENT_ID)!; + session.sessionId = 'sess-int-3'; + session.inputQueue = { push: pushSpy, close: vi.fn() }; + session.queryInstance = { + interrupt: interruptSpy, + close: vi.fn(), + stopTask: stopTaskSpy, + }; + session.activeTaskIds.set('task-abc', 'tool-1'); + session.activeTaskIds.set('task-def', 'tool-2'); + + await interruptChat(CLIENT_ID, 'Stop everything'); + + expect(stopTaskSpy).toHaveBeenCalledTimes(2); + expect(stopTaskSpy).toHaveBeenCalledWith('task-abc'); + expect(stopTaskSpy).toHaveBeenCalledWith('task-def'); + expect(interruptSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/__tests__/session-overview.test.ts b/server/__tests__/session-overview.test.ts new file mode 100644 index 00000000..5f4c47dd --- /dev/null +++ b/server/__tests__/session-overview.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SessionOverviewEmitter, type SessionOverviewDeps } from '../session-overview.js'; +import type { ActiveSessionInfo } from '@mitzo/harness'; +import type { LoopStatus } from '../task-orchestrator.js'; + +// ─── Mock helpers ───────────────────────────────────────────────────────────── + +function makeActiveSession(overrides: Partial = {}): ActiveSessionInfo { + return { + clientId: 'client-1', + sessionId: 'session-1', + mode: 'auto', + cwd: '/Users/test/tools/mitzo', + attached: true, + cumulativeSessionTokens: 0, + cumulativeCostUsd: 0, + hasSnapshot: false, + taskContext: null, + observerCount: 0, + ...overrides, + }; +} + +function idleLoopStatus(): LoopStatus { + return { + state: 'idle', + goalId: null, + activeTaskId: null, + progress: null, + specMode: false, + awaitingApproval: false, + }; +} + +function makeDeps(overrides: Partial = {}): SessionOverviewDeps { + return { + registry: { + getActiveSessions: vi.fn(() => []), + } as unknown as SessionOverviewDeps['registry'], + sseRegistry: { + broadcast: vi.fn(), + sendTo: vi.fn(), + } as unknown as SessionOverviewDeps['sseRegistry'], + getLoopStatus: vi.fn(() => idleLoopStatus()), + taskStore: { + getTree: vi.fn(() => []), + } as unknown as SessionOverviewDeps['taskStore'], + getSessionTitle: vi.fn(() => undefined), + ...overrides, + }; +} + +// ─── Mock the permissions module ────────────────────────────────────────────── + +vi.mock('@mitzo/harness', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getPendingCountBySession: vi.fn(() => 0), + }; +}); + +import { getPendingCountBySession } from '@mitzo/harness'; +const mockGetPending = getPendingCountBySession as ReturnType; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('SessionOverviewEmitter', () => { + let emitter: SessionOverviewEmitter; + let deps: SessionOverviewDeps; + + beforeEach(() => { + vi.useFakeTimers(); + mockGetPending.mockReturnValue(0); + }); + + afterEach(() => { + emitter?.destroy(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ─── getSnapshot ────────────────────────────────────────────────────────── + + it('returns empty array when no active sessions', () => { + deps = makeDeps(); + emitter = new SessionOverviewEmitter(deps); + + expect(emitter.getSnapshot()).toEqual([]); + }); + + it('skips sessions without sessionId', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ sessionId: undefined })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + expect(emitter.getSnapshot()).toEqual([]); + }); + + it('derives "working" state when session has snapshot', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ hasSnapshot: true })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + const activities = emitter.getSnapshot(); + expect(activities).toHaveLength(1); + expect(activities[0].state).toBe('working'); + }); + + it('derives "done" state when session just finished', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ hasSnapshot: false, attached: true })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + // Touch with recent timestamp + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities).toHaveLength(1); + expect(activities[0].state).toBe('done'); + }); + + it('derives "idle" state when done timeout expired', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ hasSnapshot: false, attached: true })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + // Touch, then advance time past DONE_TIMEOUT_MS (5 min) + emitter.touch('client-1'); + vi.advanceTimersByTime(6 * 60 * 1000); + + const activities = emitter.getSnapshot(); + expect(activities).toHaveLength(1); + expect(activities[0].state).toBe('idle'); + }); + + it('derives "waiting" state with permission reason', () => { + mockGetPending.mockReturnValue(1); + + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ hasSnapshot: true })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + const activities = emitter.getSnapshot(); + expect(activities).toHaveLength(1); + expect(activities[0].state).toBe('waiting'); + expect(activities[0].waitReason).toBe('permission'); + // "working" should be in flags since snapshot is true + expect(activities[0].flags).toContain('working'); + }); + + it('derives "paused" state from ATB loop', () => { + const goalId = 'goal-1'; + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ taskContext: { currentTaskId: 'task-1', goalId } }), + ]), + } as unknown as SessionOverviewDeps['registry'], + getLoopStatus: vi.fn(() => ({ + ...idleLoopStatus(), + state: 'paused' as const, + goalId, + })), + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('paused'); + }); + + it('includes ATB progress when loop is running', () => { + const goalId = 'goal-1'; + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ taskContext: { currentTaskId: 'task-1', goalId } }), + ]), + } as unknown as SessionOverviewDeps['registry'], + getLoopStatus: vi.fn(() => ({ + ...idleLoopStatus(), + state: 'running' as const, + goalId, + progress: { done: 2, total: 5 }, + })), + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('working'); + expect(activities[0].progress).toEqual({ done: 2, total: 5 }); + }); + + it('derives "waiting" with review reason from ATB awaiting approval', () => { + const goalId = 'goal-1'; + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ taskContext: { currentTaskId: 'task-1', goalId } }), + ]), + } as unknown as SessionOverviewDeps['registry'], + getLoopStatus: vi.fn(() => ({ + ...idleLoopStatus(), + state: 'running' as const, + goalId, + awaitingApproval: true, + })), + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('waiting'); + expect(activities[0].waitReason).toBe('review'); + }); + + it('derives repo name from cwd', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ cwd: '/Users/test/tools/mitzo' })]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].repo).toBe('mitzo'); + }); + + it('strips worktree suffix from repo name', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ + cwd: '/Users/test/redhat/mgmt/.claude/worktrees/abc123', + }), + ]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].repo).toBe('mgmt'); + }); + + it('uses session title from eventStore', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession()]), + } as unknown as SessionOverviewDeps['registry'], + getSessionTitle: vi.fn(() => 'Fix auth bug'), + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].title).toBe('Fix auth bug'); + }); + + it('falls back to session ID suffix when no title', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession({ sessionId: 'abcdef12-3456-7890' })]), + } as unknown as SessionOverviewDeps['registry'], + getSessionTitle: vi.fn(() => undefined), + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].title).toBe('456-7890'); + }); + + // ─── Coalescing ─────────────────────────────────────────────────────────── + + it('coalesces multiple scheduleBroadcast calls within 200ms', () => { + deps = makeDeps(); + emitter = new SessionOverviewEmitter(deps); + const broadcast = deps.sseRegistry.broadcast as ReturnType; + + emitter.scheduleBroadcast(); + emitter.scheduleBroadcast(); + emitter.scheduleBroadcast(); + + expect(broadcast).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith('session_activity', []); + }); + + it('broadcasts again after coalesce window', () => { + deps = makeDeps(); + emitter = new SessionOverviewEmitter(deps); + const broadcast = deps.sseRegistry.broadcast as ReturnType; + + emitter.scheduleBroadcast(); + vi.advanceTimersByTime(200); + expect(broadcast).toHaveBeenCalledTimes(1); + + emitter.scheduleBroadcast(); + vi.advanceTimersByTime(200); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + // ─── touch / forget ─────────────────────────────────────────────────────── + + it('forget removes tracked session', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [makeActiveSession()]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + + emitter.touch('client-1'); + emitter.forget('client-1'); + + // After forget, lastEventAt defaults to now → state depends on current time + const activities = emitter.getSnapshot(); + expect(activities).toHaveLength(1); + // Session still shows (from registry), just with fresh timestamp + }); + + // ─── Task wait state ────────────────────────────────────────────────────── + + it('derives "waiting" with blocked reason from task store', () => { + const goalId = 'goal-1'; + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ taskContext: { currentTaskId: 'task-1', goalId } }), + ]), + } as unknown as SessionOverviewDeps['registry'], + taskStore: { + getTree: vi.fn(() => [ + { id: goalId, parentId: null, status: 'active' }, + { id: 'child-1', parentId: goalId, status: 'blocked' }, + ]), + } as unknown as SessionOverviewDeps['taskStore'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('waiting'); + expect(activities[0].waitReason).toBe('blocked'); + }); + + it('derives "waiting" with review reason from pending_review task', () => { + const goalId = 'goal-1'; + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ taskContext: { currentTaskId: 'task-1', goalId } }), + ]), + } as unknown as SessionOverviewDeps['registry'], + taskStore: { + getTree: vi.fn(() => [ + { id: goalId, parentId: null, status: 'active' }, + { id: 'child-1', parentId: goalId, status: 'pending_review' }, + ]), + } as unknown as SessionOverviewDeps['taskStore'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('waiting'); + expect(activities[0].waitReason).toBe('review'); + }); + + // ─── Detached sessions ──────────────────────────────────────────────────── + + it('derives "done" for recently detached session', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ attached: false, hasSnapshot: false }), + ]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('done'); + }); + + it('derives "idle" for detached session past timeout', () => { + deps = makeDeps({ + registry: { + getActiveSessions: vi.fn(() => [ + makeActiveSession({ attached: false, hasSnapshot: false }), + ]), + } as unknown as SessionOverviewDeps['registry'], + }); + emitter = new SessionOverviewEmitter(deps); + emitter.touch('client-1'); + vi.advanceTimersByTime(6 * 60 * 1000); + + const activities = emitter.getSnapshot(); + expect(activities[0].state).toBe('idle'); + }); +}); diff --git a/server/__tests__/subagent-events.test.ts b/server/__tests__/subagent-events.test.ts new file mode 100644 index 00000000..7497e84b --- /dev/null +++ b/server/__tests__/subagent-events.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { runQueryLoop } from '../query-loop.js'; +import type { SessionRegistry } from '../session-registry.js'; +import type { SessionTransport } from '@mitzo/harness'; + +describe('Subagent Event Emission', () => { + let mockRegistry: SessionRegistry; + let mockTransport: SessionTransport; + let sentEvents: Record[]; + let abortController: AbortController; + + beforeEach(() => { + sentEvents = []; + abortController = new AbortController(); + + mockTransport = { + send: vi.fn((data: Record) => { + sentEvents.push(data); + }), + isOpen: vi.fn(() => true), + close: vi.fn(), + } as unknown as SessionTransport; + + mockRegistry = { + get: vi.fn(() => ({ + transport: mockTransport, + sessionId: 'test-session', + cumulativeSessionTokens: 0, + cumulativeCostUsd: 0, + currentSnapshot: null, + cwd: '/test', + mode: 'agent' as const, + observers: new Set(), + })), + setSessionId: vi.fn(), + isAttached: vi.fn(() => true), + isSuspended: vi.fn(() => false), + bufferEvent: vi.fn(), + remove: vi.fn(), + } as unknown as SessionRegistry; + }); + + it('emits subagent_start when parent_tool_use_id is detected', async () => { + const events = [ + { + type: 'assistant', + session_id: 'test-session', + }, + { + type: 'stream_event', + event: { + type: 'message_start', + message: { + id: 'msg-parent', + usage: { input_tokens: 100 }, + }, + }, + parent_tool_use_id: null, + }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'tool-agent-1', + name: 'Agent', + }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_stop', + index: 0, + }, + }, + // Subagent message_start with parent_tool_use_id + { + type: 'stream_event', + event: { + type: 'message_start', + message: { + id: 'msg-sub-1', + usage: { input_tokens: 50 }, + }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'result', + session_id: 'test-session', + usage: { input_tokens: 150, output_tokens: 100 }, + }, + ]; + + async function* gen() { + for (const evt of events) { + yield evt; + } + } + + await runQueryLoop(gen(), 'test-client', mockRegistry, abortController); + + const subagentStart = sentEvents.find((e) => e.type === 'subagent_start'); + expect(subagentStart).toBeDefined(); + expect(subagentStart).toMatchObject({ + v: 2, + type: 'subagent_start', + parentToolId: 'tool-agent-1', + subagentMessageId: 'msg-sub-1', + }); + }); + + it('wraps subagent content blocks in subagent_block_* events', async () => { + const events = [ + { + type: 'assistant', + session_id: 'test-session', + }, + { + type: 'stream_event', + event: { + type: 'message_start', + message: { id: 'msg-parent', usage: { input_tokens: 100 } }, + }, + parent_tool_use_id: null, + }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-agent-1', name: 'Agent' }, + }, + }, + { + type: 'stream_event', + event: { type: 'content_block_stop', index: 0 }, + }, + // Subagent turn + { + type: 'stream_event', + event: { + type: 'message_start', + message: { id: 'msg-sub-1', usage: { input_tokens: 50 } }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking' }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Analyzing...' }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'stream_event', + event: { type: 'content_block_stop', index: 0 }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'result', + session_id: 'test-session', + usage: { input_tokens: 150, output_tokens: 100 }, + }, + ]; + + async function* gen() { + for (const evt of events) { + yield evt; + } + } + + await runQueryLoop(gen(), 'test-client', mockRegistry, abortController); + + expect(sentEvents.some((e) => e.type === 'subagent_block_start')).toBe(true); + expect(sentEvents.some((e) => e.type === 'subagent_block_delta')).toBe(true); + expect(sentEvents.some((e) => e.type === 'subagent_block_end')).toBe(true); + }); + + it('emits subagent_end with usage on subagent message_end', async () => { + const events = [ + { + type: 'assistant', + session_id: 'test-session', + }, + { + type: 'stream_event', + event: { + type: 'message_start', + message: { id: 'msg-parent', usage: { input_tokens: 100 } }, + }, + parent_tool_use_id: null, + }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-agent-1', name: 'Agent' }, + }, + }, + { + type: 'stream_event', + event: { type: 'content_block_stop', index: 0 }, + }, + // Subagent turn + { + type: 'stream_event', + event: { + type: 'message_start', + message: { id: 'msg-sub-1', usage: { input_tokens: 50, output_tokens: 25 } }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 0, + content_block: { type: 'text' }, + }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'stream_event', + event: { type: 'content_block_stop', index: 0 }, + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'assistant', + parent_tool_use_id: 'tool-agent-1', + }, + { + type: 'result', + session_id: 'test-session', + usage: { input_tokens: 150, output_tokens: 125 }, + }, + ]; + + async function* gen() { + for (const evt of events) { + yield evt; + } + } + + await runQueryLoop(gen(), 'test-client', mockRegistry, abortController); + + const subagentEnd = sentEvents.find((e) => e.type === 'subagent_end'); + expect(subagentEnd).toBeDefined(); + expect(subagentEnd).toMatchObject({ + v: 2, + type: 'subagent_end', + usage: { + inputTokens: 50, + outputTokens: 25, + }, + }); + }); +}); diff --git a/server/__tests__/workload-routes.test.ts b/server/__tests__/workload-routes.test.ts new file mode 100644 index 00000000..c599c7ce --- /dev/null +++ b/server/__tests__/workload-routes.test.ts @@ -0,0 +1,520 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import type { Express } from 'express'; +import request from 'supertest'; +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const TEST_REPO = join(tmpdir(), `mitzo-workload-routes-test-${process.pid}`); + +vi.mock('../chat.js', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { join: pjoin } = require('path'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { tmpdir: ptmpdir } = require('os'); + const repo = pjoin(ptmpdir(), `mitzo-workload-routes-test-${process.pid}`); + return { + getSessions: vi.fn().mockResolvedValue({ sessions: [], hasMore: false }), + getMessages: vi.fn().mockResolvedValue([]), + renameSessionById: vi.fn().mockResolvedValue(undefined), + hideSession: vi.fn(), + hideAllSessions: vi.fn(), + BASE_REPO: repo, + getRepoConfig: vi.fn(() => ({ + quickActions: [], + allowedPaths: [], + roots: [{ label: 'Main', path: repo }], + resolvedVenvPaths: [], + toolTierOverrides: {}, + inboxPath: 'mgmt_lib/inbox', + resolvedInboxPath: pjoin(repo, 'mgmt_lib/inbox'), + repos: {}, + contextBlocks: {}, + })), + getMcpServerNames: vi.fn().mockReturnValue([]), + AVAILABLE_MODELS: [{ id: 'test-model', label: 'Test', desc: 'Test model' }], + registry: { get: vi.fn() }, + eventStore: { getEventsAfter: vi.fn().mockReturnValue([]) }, + setTaskStore: vi.fn(), + }; +}); + +vi.mock('../permissions.js', () => ({ + resolvePending: vi.fn().mockReturnValue(true), +})); + +vi.mock('../worktree.js', () => ({ + listWorktrees: vi.fn().mockReturnValue([]), + cleanupStaleWorktrees: vi.fn(), +})); + +vi.mock('../git-version.js', () => ({ + getLocalCommit: vi.fn().mockReturnValue('abc1234'), + isUpdateAvailable: vi.fn().mockReturnValue(false), +})); + +let app: Express; +let authCookie: string; + +async function getAuthCookie(agent: request.Agent): Promise { + const res = await agent.post('/api/auth/login').send({ passphrase: process.env.AUTH_PASSPHRASE }); + const setCookie = res.headers['set-cookie']; + if (!setCookie) throw new Error('No cookie returned from login'); + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + return cookies[0].split(';')[0]; +} + +beforeAll(async () => { + mkdirSync(TEST_REPO, { recursive: true }); + mkdirSync(join(TEST_REPO, 'mgmt_lib', 'inbox', 'archive'), { recursive: true }); + mkdirSync(join(TEST_REPO, '.mitzo'), { recursive: true }); + + const mod = await import('../app.js'); + app = mod.app; + + const agent = request(app); + authCookie = await getAuthCookie(agent); +}); + +afterAll(async () => { + // Clean up stores + try { + const mod = await import('../app.js'); + if (mod.taskStore?.close) mod.taskStore.close(); + if (mod.workloadStore?.close) mod.workloadStore.close(); + } catch { + // ignore + } + try { + rmSync(TEST_REPO, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +describe('workload routes', () => { + // --- Auth --- + + it('POST /api/workload/signals — unauthenticated returns 401', async () => { + const res = await request(app).post('/api/workload/signals').send({ + sourceType: 'test', + sourceId: '123', + url: 'https://example.com', + title: 'Test', + author: 'Test Author', + timestamp: new Date().toISOString(), + }); + expect(res.status).toBe(401); + }); + + it('GET /api/workload/items — unauthenticated returns 401', async () => { + const res = await request(app).get('/api/workload/items'); + expect(res.status).toBe(401); + }); + + // --- POST /api/workload/signals --- + + it('POST /api/workload/signals — creates new item and source', async () => { + const signal = { + sourceType: 'github', + sourceId: 'pr-123', + url: 'https://github.com/org/repo/pull/123', + title: 'Fix critical bug', + snippet: 'This PR fixes a critical issue', + author: 'alice', + timestamp: new Date().toISOString(), + profile: 'default', + urgencyHint: 0.8, + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + created: true, + item: { + title: signal.title, + snippet: signal.snippet, + status: 'active', + profile: 'default', + starred: false, + }, + }); + expect(res.body.item.sources).toHaveLength(1); + expect(res.body.item.sources[0]).toMatchObject({ + sourceType: 'github', + sourceId: 'pr-123', + title: signal.title, + }); + }); + + it('POST /api/workload/signals — deduplicates by (sourceType, sourceId)', async () => { + const signal = { + sourceType: 'jira', + sourceId: 'PROJ-456', + url: 'https://jira.example.com/browse/PROJ-456', + title: 'Implement feature', + author: 'bob', + timestamp: new Date().toISOString(), + }; + + // First call creates + const res1 = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + expect(res1.status).toBe(201); + expect(res1.body.created).toBe(true); + + // Second call with same sourceType + sourceId returns existing + const res2 = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send({ ...signal, timestamp: new Date().toISOString() }); + expect(res2.status).toBe(200); + expect(res2.body.created).toBe(false); + expect(res2.body.item.id).toBe(res1.body.item.id); + }); + + it('POST /api/workload/signals — validates timestamp format', async () => { + const signal = { + sourceType: 'test', + sourceId: 'invalid-ts', + url: 'https://example.com', + title: 'Test', + author: 'Test', + timestamp: 'not-a-valid-date', + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(400); + }); + + it('POST /api/workload/signals — validates URL format', async () => { + const signal = { + sourceType: 'test', + sourceId: 'bad-url', + url: 'not a url', + title: 'Test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(400); + }); + + it('POST /api/workload/signals — merges context hints', async () => { + const signal = { + sourceType: 'test', + sourceId: 'hints-test', + url: 'https://example.com', + title: 'Test hints', + author: 'Test', + timestamp: new Date().toISOString(), + contextHints: { + repos: ['repo1', 'repo2'], + keywords: ['urgent'], + taskHint: 'Review and merge', + }, + }; + + const res = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + + expect(res.status).toBe(201); + expect(res.body.item.contextHints).toMatchObject({ + repos: ['repo1', 'repo2'], + keywords: ['urgent'], + taskHint: 'Review and merge', + }); + }); + + // --- POST /api/workload/signals/batch --- + + it('POST /api/workload/signals/batch — ingests multiple signals', async () => { + const signals = [ + { + sourceType: 'batch-test', + sourceId: 'batch-1', + url: 'https://example.com/1', + title: 'Batch item 1', + author: 'Test', + timestamp: new Date().toISOString(), + }, + { + sourceType: 'batch-test', + sourceId: 'batch-2', + url: 'https://example.com/2', + title: 'Batch item 2', + author: 'Test', + timestamp: new Date().toISOString(), + }, + ]; + + const res = await request(app) + .post('/api/workload/signals/batch') + .set('Cookie', authCookie) + .send({ signals }); + + expect(res.status).toBe(201); + expect(res.body.items).toHaveLength(2); + expect(res.body.created).toBe(2); + expect(res.body.total).toBe(2); + }); + + it('POST /api/workload/signals/batch — validates max batch size', async () => { + const signals = Array.from({ length: 101 }, (_, i) => ({ + sourceType: 'batch', + sourceId: `batch-${i}`, + url: `https://example.com/${i}`, + title: `Item ${i}`, + author: 'Test', + timestamp: new Date().toISOString(), + })); + + const res = await request(app) + .post('/api/workload/signals/batch') + .set('Cookie', authCookie) + .send({ signals }); + + expect(res.status).toBe(400); + }); + + // --- GET /api/workload/items --- + + it('GET /api/workload/items — lists all items', async () => { + const res = await request(app).get('/api/workload/items').set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('items'); + expect(res.body).toHaveProperty('profiles'); + expect(Array.isArray(res.body.items)).toBe(true); + }); + + it('GET /api/workload/items — filters by status', async () => { + // Create item and complete it + const signal = { + sourceType: 'status-test', + sourceId: 'st-1', + url: 'https://example.com', + title: 'Status test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ status: 'completed' }); + + const res = await request(app) + .get('/api/workload/items?status=completed') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.items.some((item: { id: string }) => item.id === itemId)).toBe(true); + }); + + // --- GET /api/workload/items/:id --- + + it('GET /api/workload/items/:id — returns item by ID', async () => { + const signal = { + sourceType: 'get-test', + sourceId: 'gt-1', + url: 'https://example.com', + title: 'Get test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app).get(`/api/workload/items/${itemId}`).set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.item.id).toBe(itemId); + }); + + it('GET /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .get('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + }); + + // --- PATCH /api/workload/items/:id --- + + it('PATCH /api/workload/items/:id — updates item fields', async () => { + const signal = { + sourceType: 'update-test', + sourceId: 'ut-1', + url: 'https://example.com', + title: 'Update test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ + title: 'Updated title', + starred: true, + urgency: 0.9, + }); + + expect(res.status).toBe(200); + expect(res.body.item).toMatchObject({ + id: itemId, + title: 'Updated title', + starred: true, + urgency: 0.9, + }); + }); + + it('PATCH /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .patch('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie) + .send({ title: 'New title' }); + + expect(res.status).toBe(404); + }); + + it('PATCH /api/workload/items/:id — validates input', async () => { + const signal = { + sourceType: 'validation-test', + sourceId: 'vt-1', + url: 'https://example.com', + title: 'Validation test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .patch(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie) + .send({ urgency: 2.0 }); // Invalid: > 1.0 + + expect(res.status).toBe(400); + }); + + // --- DELETE /api/workload/items/:id --- + + it('DELETE /api/workload/items/:id — deletes item', async () => { + const signal = { + sourceType: 'delete-test', + sourceId: 'dt-1', + url: 'https://example.com', + title: 'Delete test', + author: 'Test', + timestamp: new Date().toISOString(), + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .delete(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + + // Verify deletion + const getRes = await request(app) + .get(`/api/workload/items/${itemId}`) + .set('Cookie', authCookie); + expect(getRes.status).toBe(404); + }); + + it('DELETE /api/workload/items/:id — returns 404 for missing item', async () => { + const res = await request(app) + .delete('/api/workload/items/nonexistent-id') + .set('Cookie', authCookie); + + expect(res.status).toBe(404); + }); + + // --- POST /api/workload/items/:id/promote --- + + it('POST /api/workload/items/:id/promote — creates task from item', async () => { + const signal = { + sourceType: 'promote-test', + sourceId: 'pt-1', + url: 'https://example.com', + title: 'Promote test', + snippet: 'This should become a task', + author: 'Test', + timestamp: new Date().toISOString(), + contextHints: { + repos: ['test-repo'], + taskHint: 'Review carefully', + }, + }; + const createRes = await request(app) + .post('/api/workload/signals') + .set('Cookie', authCookie) + .send(signal); + const itemId = createRes.body.item.id; + + const res = await request(app) + .post(`/api/workload/items/${itemId}/promote`) + .set('Cookie', authCookie) + .send({ description: 'Additional context' }); + + expect(res.status).toBe(201); + expect(res.body.task).toMatchObject({ + title: signal.title, + status: 'pending', + }); + expect(res.body.task.description).toContain('Additional context'); + expect(res.body.task.description).toContain('Review carefully'); + expect(res.body.item.status).toBe('acknowledged'); + expect(res.body.item.goalId).toBe(res.body.task.id); + }); + + it('POST /api/workload/items/:id/promote — returns 404 for missing item', async () => { + const res = await request(app) + .post('/api/workload/items/nonexistent-id/promote') + .set('Cookie', authCookie) + .send({}); + + expect(res.status).toBe(404); + }); +}); diff --git a/server/__tests__/workload-store.test.ts b/server/__tests__/workload-store.test.ts new file mode 100644 index 00000000..93a183eb --- /dev/null +++ b/server/__tests__/workload-store.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import Database from 'better-sqlite3'; +import { WorkloadStore } from '../workload-store.js'; +import type { WorkSignal } from '../workload-store.js'; + +const TEST_DIR = join(tmpdir(), `mitzo-workload-test-${process.pid}`); + +let db: Database.Database; +let store: WorkloadStore; + +function makeSignal(overrides?: Partial): WorkSignal { + return { + sourceType: 'manual', + sourceId: `test-${Date.now()}-${Math.random()}`, + url: 'https://example.com', + title: 'Test item', + snippet: 'A test work signal', + author: 'test-user', + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + db = new Database(join(TEST_DIR, `workload-${Date.now()}.db`)); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + store = new WorkloadStore(db); +}); + +afterEach(() => { + store.close(); + db.close(); + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +describe('WorkloadStore', () => { + describe('ingest', () => { + it('creates a new item from a signal', () => { + const signal = makeSignal({ title: 'Review PR #42' }); + const { item, created } = store.ingest(signal); + + expect(created).toBe(true); + expect(item.title).toBe('Review PR #42'); + expect(item.status).toBe('active'); + expect(item.sources).toHaveLength(1); + expect(item.sources[0].sourceType).toBe('manual'); + expect(item.sources[0].sourceId).toBe(signal.sourceId); + }); + + it('deduplicates by sourceType + sourceId', () => { + const signal = makeSignal({ sourceType: 'jira', sourceId: 'RHAIENG-100' }); + const first = store.ingest(signal); + const second = store.ingest(signal); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(first.item.id).toBe(second.item.id); + }); + + it('applies urgency hint', () => { + const signal = makeSignal({ urgencyHint: 0.8 }); + const { item } = store.ingest(signal); + expect(item.urgency).toBeGreaterThanOrEqual(0.8); + }); + + it('applies age boost for old signals', () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + const signal = makeSignal({ + timestamp: oldDate.toISOString(), + urgencyHint: 0.3, + }); + const { item } = store.ingest(signal); + // Age > 7 days = +0.1 boost + expect(item.urgency).toBeGreaterThanOrEqual(0.4); + }); + + it('stores context hints', () => { + const signal = makeSignal({ + contextHints: { + repos: ['dimakis/mgmt'], + jiraKeys: ['RHAIENG-100'], + keywords: ['auth', 'refactor'], + }, + }); + const { item } = store.ingest(signal); + expect(item.contextHints.repos).toEqual(['dimakis/mgmt']); + expect(item.contextHints.jiraKeys).toEqual(['RHAIENG-100']); + expect(item.contextHints.keywords).toEqual(['auth', 'refactor']); + }); + + it('respects profile from signal', () => { + const signal = makeSignal({ profile: 'work' }); + const { item } = store.ingest(signal); + expect(item.profile).toBe('work'); + }); + + it('defaults profile to "default"', () => { + const signal = makeSignal(); + const { item } = store.ingest(signal); + expect(item.profile).toBe('default'); + }); + + it('throws on invalid timestamp format', () => { + const signal = makeSignal({ timestamp: 'not-a-date' }); + expect(() => store.ingest(signal)).toThrow('Invalid timestamp format'); + }); + }); + + describe('ingestBatch', () => { + it('ingests multiple signals in a transaction', () => { + const signals = [ + makeSignal({ title: 'Item 1', sourceId: 'batch-1' }), + makeSignal({ title: 'Item 2', sourceId: 'batch-2' }), + makeSignal({ title: 'Item 3', sourceId: 'batch-3' }), + ]; + const result = store.ingestBatch(signals); + expect(result.items).toHaveLength(3); + expect(result.created).toBe(3); + }); + + it('deduplicates within batch', () => { + const signals = [ + makeSignal({ sourceId: 'same-id', title: 'First' }), + makeSignal({ sourceId: 'same-id', title: 'Duplicate' }), + ]; + const result = store.ingestBatch(signals); + expect(result.items).toHaveLength(2); + expect(result.created).toBe(1); // second is a dedup + }); + }); + + describe('list', () => { + it('returns items sorted by starred then urgency', () => { + store.ingest(makeSignal({ title: 'Low', urgencyHint: 0.1, sourceId: 'low' })); + store.ingest(makeSignal({ title: 'High', urgencyHint: 0.9, sourceId: 'high' })); + + const items = store.list(); + expect(items).toHaveLength(2); + expect(items[0].title).toBe('High'); + expect(items[1].title).toBe('Low'); + }); + + it('filters by profile', () => { + store.ingest(makeSignal({ profile: 'work', sourceId: 'w1' })); + store.ingest(makeSignal({ profile: 'personal', sourceId: 'p1' })); + + const work = store.list({ profile: 'work' }); + expect(work).toHaveLength(1); + expect(work[0].profile).toBe('work'); + }); + + it('filters by status', () => { + const { item } = store.ingest(makeSignal({ sourceId: 'ack-me' })); + store.update(item.id, { status: 'acknowledged' }); + store.ingest(makeSignal({ sourceId: 'active-one' })); + + const active = store.list({ status: 'active' }); + expect(active).toHaveLength(1); + }); + + it('filters by starred', () => { + const { item } = store.ingest(makeSignal({ sourceId: 'star-me' })); + store.update(item.id, { starred: true }); + store.ingest(makeSignal({ sourceId: 'unstarred' })); + + const starred = store.list({ starred: true }); + expect(starred).toHaveLength(1); + expect(starred[0].starred).toBe(true); + }); + }); + + describe('update', () => { + it('updates status', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { status: 'acknowledged' }); + expect(updated?.status).toBe('acknowledged'); + }); + + it('updates starred', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { starred: true }); + expect(updated?.starred).toBe(true); + }); + + it('sets snoozed status when snoozedUntil is set', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.update(item.id, { snoozedUntil: '2026-05-01' }); + expect(updated?.status).toBe('snoozed'); + expect(updated?.snoozedUntil).toBe('2026-05-01'); + }); + + it('merges context hints on update', () => { + const { item } = store.ingest( + makeSignal({ contextHints: { repos: ['repo-a'], keywords: ['old'] } }), + ); + const updated = store.update(item.id, { + contextHints: { repos: ['repo-b'], keywords: ['new'] }, + }); + expect(updated?.contextHints.repos).toEqual(['repo-a', 'repo-b']); + expect(updated?.contextHints.keywords).toEqual(['old', 'new']); + }); + + it('returns null for non-existent item', () => { + const result = store.update('nonexistent', { status: 'completed' }); + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('deletes an item and its sources', () => { + const { item } = store.ingest(makeSignal()); + expect(store.delete(item.id)).toBe(true); + expect(store.get(item.id)).toBeNull(); + }); + + it('returns false for non-existent item', () => { + expect(store.delete('nonexistent')).toBe(false); + }); + }); + + describe('setGoalId', () => { + it('links item to goal and sets acknowledged', () => { + const { item } = store.ingest(makeSignal()); + const updated = store.setGoalId(item.id, 'goal-123'); + expect(updated?.goalId).toBe('goal-123'); + expect(updated?.status).toBe('acknowledged'); + }); + }); + + describe('completeByGoal', () => { + it('completes items linked to a goal', () => { + const { item } = store.ingest(makeSignal()); + store.setGoalId(item.id, 'goal-456'); + store.completeByGoal('goal-456'); + const completed = store.get(item.id); + expect(completed?.status).toBe('completed'); + }); + }); + + describe('unsnoozeDue', () => { + it('unsnoozes items past their snooze date', () => { + const { item } = store.ingest(makeSignal()); + store.update(item.id, { snoozedUntil: '2020-01-01' }); // past date + const count = store.unsnoozeDue(); + expect(count).toBe(1); + const updated = store.get(item.id); + expect(updated?.status).toBe('active'); + expect(updated?.snoozedUntil).toBeNull(); + }); + }); + + describe('profiles', () => { + it('returns profile counts excluding completed', () => { + store.ingest(makeSignal({ profile: 'work', sourceId: 'w1' })); + store.ingest(makeSignal({ profile: 'work', sourceId: 'w2' })); + store.ingest(makeSignal({ profile: 'personal', sourceId: 'p1' })); + + const profiles = store.profiles(); + expect(profiles).toEqual([ + { profile: 'work', count: 2 }, + { profile: 'personal', count: 1 }, + ]); + }); + }); +}); diff --git a/server/__tests__/ws-handler-v2.test.ts b/server/__tests__/ws-handler-v2.test.ts index bfcb30e3..62b3cf1e 100644 --- a/server/__tests__/ws-handler-v2.test.ts +++ b/server/__tests__/ws-handler-v2.test.ts @@ -283,6 +283,40 @@ describe('handleReconnect', () => { expect(reattachChat).not.toHaveBeenCalled(); }); + + it('resets cursor to client lastSeq immediately after watch (before replay)', () => { + const eventStore = mockEventStore(); + eventStore.getEventsAfter.mockReturnValue([ + { seq: 51, payload: { type: 'msg1' } }, + { seq: 52, payload: { type: 'msg2' } }, + ]); + + const ctx = createContext({ + eventStore: eventStore as unknown as V2HandlerContext['eventStore'], + }); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + // Spy on resetCursor to verify it's called early + const resetSpy = vi.spyOn(ctx.connRegistry, 'resetCursor'); + + handleReconnect( + 'c1', + { type: 'reconnect', sessions: [{ sessionId: 'sess-1', lastSeq: 50 }] }, + ctx, + ); + + // resetCursor should have been called at least twice: + // 1. Before replay (with client's lastSeq) + // 2. After replay (with final replayed seq) + expect(resetSpy).toHaveBeenCalledTimes(2); + // First call sets cursor to client's lastSeq + expect(resetSpy).toHaveBeenNthCalledWith(1, 'c1', 'sess-1', 50); + // Second call sets cursor to last replayed seq + expect(resetSpy).toHaveBeenNthCalledWith(2, 'c1', 'sess-1', 52); + + resetSpy.mockRestore(); + }); }); // ─── handleWatch / handleUnwatch ───────────────────────────────────────────── diff --git a/server/api-schemas.ts b/server/api-schemas.ts index ac109e32..c17e3faf 100644 --- a/server/api-schemas.ts +++ b/server/api-schemas.ts @@ -182,3 +182,46 @@ export const SignalBody = z.object({ status: z.enum(['pass', 'fail']), artifacts: z.record(z.string(), z.unknown()).optional(), }); + +// -- Workload schemas -- + +const WorkSignalContextHints = z.object({ + repos: z.array(z.string()).optional(), + paths: z.array(z.string()).optional(), + issues: z.array(z.string()).optional(), + docIds: z.array(z.string()).optional(), + people: z.array(z.string()).optional(), + jiraKeys: z.array(z.string()).optional(), + keywords: z.array(z.string()).optional(), + taskHint: z.string().optional(), +}); + +export const WorkSignalBody = z.object({ + sourceType: z.string().min(1), + sourceId: z.string().min(1), + url: z.string().url(), + title: z.string().min(1), + snippet: z.string().default(''), + author: z.string().min(1), + timestamp: z.string().datetime({ offset: true }), + contextHints: WorkSignalContextHints.optional(), + urgencyHint: z.number().min(0).max(1).optional(), + profile: z.string().optional(), +}); + +export const WorkSignalBatchBody = z.object({ + signals: z.array(WorkSignalBody).min(1).max(100), +}); + +export const WorkloadItemUpdateBody = z.object({ + title: z.string().optional(), + status: z.enum(['active', 'acknowledged', 'snoozed', 'completed']).optional(), + starred: z.boolean().optional(), + snoozedUntil: z.string().nullable().optional(), + urgency: z.number().min(0).max(1).optional(), + contextHints: WorkSignalContextHints.optional(), +}); + +export const WorkloadPromoteBody = z.object({ + description: z.string().optional(), +}); diff --git a/server/apns.ts b/server/apns.ts index 4eae8ff4..d75fd167 100644 --- a/server/apns.ts +++ b/server/apns.ts @@ -12,6 +12,7 @@ const APNS_KEY_PATH = process.env.APNS_KEY_PATH; const APNS_KEY_ID = process.env.APNS_KEY_ID; const APNS_TEAM_ID = process.env.APNS_TEAM_ID; const APNS_BUNDLE_ID = process.env.APNS_BUNDLE_ID || 'com.mitzo.app'; +const APNS_PRODUCTION = process.env.APNS_PRODUCTION !== 'false'; let tokens: string[] = []; let tokenStorePath: string | null = null; @@ -76,8 +77,9 @@ function getProvider(): import('@parse/node-apn').Provider | null { keyId: APNS_KEY_ID!, teamId: APNS_TEAM_ID!, }, - production: true, + production: APNS_PRODUCTION, }); + log.info('APNs provider initialized', { production: APNS_PRODUCTION }); return apnProvider; } catch (err: unknown) { log.error('failed to initialize APNs provider', { diff --git a/server/app.ts b/server/app.ts index 1b34a59b..8fcadb48 100644 --- a/server/app.ts +++ b/server/app.ts @@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from ' import { join, dirname, resolve, extname } from 'path'; import { execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; -import { createHash } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { login, authMiddleware, verifyToken, COOKIE_NAME, MAX_AGE_HOURS } from './auth.js'; @@ -60,8 +60,13 @@ import { WorkflowInstantiateBody, TemplateCreateBody, SignalBody, + WorkSignalBody, + WorkSignalBatchBody, + WorkloadItemUpdateBody, + WorkloadPromoteBody, } from './api-schemas.js'; import type { TaskOrchestrator } from './task-orchestrator.js'; +import type { SessionOverviewEmitter } from './session-overview.js'; import type { WorkflowTemplateStore, TemplateCreateInput } from './workflow-templates.js'; import { instantiateTemplate } from './workflow-templates.js'; import type { SignalProcessor } from './signal-processor.js'; @@ -78,6 +83,8 @@ import { SkillRegistry } from './skills.js'; import { mkdirSync } from 'fs'; import { homedir } from 'os'; import { TaskStore, type TaskCreateInput, type TaskUpdateInput } from './task-store.js'; +import { SseRegistry } from '@mitzo/harness'; +import { WorkloadStore, type WorkSignal, type TodoItemUpdateInput } from './workload-store.js'; const log = createLogger('server'); @@ -202,14 +209,25 @@ let updateAvailable = false; let onUpdateAvailable: (() => void) | null = null; let onInboxUpdated: (() => void) | null = null; let onTaskBroadcast: ((event: Record) => void) | null = null; +let onWorkloadBroadcast: ((event: Record) => void) | null = null; let orchestrator: TaskOrchestrator | null = null; let templateStore: WorkflowTemplateStore | null = null; let signalProcessor: SignalProcessor | null = null; +let overviewEmitter: SessionOverviewEmitter | null = null; +let healthMonitor: { getSnapshot: () => unknown } | null = null; export function setOrchestrator(o: TaskOrchestrator): void { orchestrator = o; } +export function setOverviewEmitter(emitter: SessionOverviewEmitter): void { + overviewEmitter = emitter; +} + +export function setHealthMonitor(monitor: { getSnapshot: () => unknown }): void { + healthMonitor = monitor; +} + export function setTemplateStore(ts: WorkflowTemplateStore): void { templateStore = ts; } @@ -228,8 +246,11 @@ try { } export const taskStore = new TaskStore(join(mitzoDir, 'tasks.db')); setTaskStore(taskStore); +export const workloadStore = new WorkloadStore(taskStore.getDatabase()); setTokenStorePath(join(mitzoDir, 'device-tokens.json')); +export const sseRegistry = new SseRegistry(); + export function setUpdateBroadcast(fn: () => void) { onUpdateAvailable = fn; } @@ -242,6 +263,10 @@ export function setTaskBroadcast(fn: (event: Record) => void) { onTaskBroadcast = fn; } +export function setWorkloadBroadcast(fn: (event: Record) => void) { + onWorkloadBroadcast = fn; +} + /** Broadcast inbox_updated to all connected WS clients. */ export function broadcastInboxUpdate() { onInboxUpdated?.(); @@ -476,6 +501,40 @@ app.post('/api/sessions/suspend', (req, res) => { app.use('/api', authMiddleware); +// --- SSE Event Bus --- + +app.get('/api/events', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', // nginx: don't buffer SSE + }); + + const clientId = randomUUID(); + sseRegistry.add(clientId, res); + + // Hydrate: send server version + session overview on connect + sseRegistry.sendTo(clientId, 'connected', { + serverVersion: buildHash, + }); + + if (overviewEmitter) { + sseRegistry.sendTo(clientId, 'session_activity', overviewEmitter.getSnapshot()); + } + + if (healthMonitor) { + sseRegistry.sendTo(clientId, 'health', healthMonitor.getSnapshot()); + } + + req.on('close', () => sseRegistry.remove(clientId)); +}); + +// REST fallback for service health (iOS WebKit can't do SSE with self-signed certs) +app.get('/api/service-health', (_req, res) => { + res.json(healthMonitor?.getSnapshot() ?? { services: [], checkedAt: 0 }); +}); + // --- Task Board API --- app.get('/api/tasks', (_req, res) => { @@ -948,6 +1007,7 @@ app.get('/api/sessions', async (req, res) => { ? meta.inputTokens + meta.outputTokens + meta.cacheReadTokens + meta.cacheCreationTokens : undefined, numTurns: meta?.numTurns, + closedBy: meta?.closedBy ?? undefined, }; }); } @@ -1505,6 +1565,127 @@ app.post('/api/todos/:id/action', async (req, res) => { } }); +// --- Workload API --- + +app.post('/api/workload/signals', (req, res) => { + const body = WorkSignalBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid signal' }); + return; + } + const result = workloadStore.ingest(body.data as WorkSignal); + res.status(result.created ? 201 : 200).json({ item: result.item, created: result.created }); + + // Broadcast workload item change + const eventType = result.created ? 'workload_item_created' : 'workload_item_updated'; + onWorkloadBroadcast?.({ type: eventType, item: result.item }); +}); + +app.post('/api/workload/signals/batch', (req, res) => { + const body = WorkSignalBatchBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid batch' }); + return; + } + const result = workloadStore.ingestBatch(body.data.signals as WorkSignal[]); + res + .status(201) + .json({ items: result.items, created: result.created, total: result.items.length }); + + // Broadcast batch workload changes + if (result.items.length > 0) { + onWorkloadBroadcast?.({ + type: 'workload_batch_updated', + items: result.items, + created: result.created, + }); + } +}); + +app.get('/api/workload/items', (req, res) => { + const profile = req.query.profile as string | undefined; + const status = req.query.status as string | undefined; + const starred = req.query.starred === 'true' ? true : undefined; + const items = workloadStore.list({ + profile, + status: status as 'active' | 'acknowledged' | 'snoozed' | 'completed' | undefined, + starred, + }); + const profiles = workloadStore.profiles(); + res.json({ items, profiles }); +}); + +app.get('/api/workload/items/:id', (req, res) => { + const item = workloadStore.get(req.params.id); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ item }); +}); + +app.patch('/api/workload/items/:id', (req, res) => { + const body = WorkloadItemUpdateBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid update' }); + return; + } + const item = workloadStore.update(req.params.id, body.data as TodoItemUpdateInput); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ item }); + onWorkloadBroadcast?.({ type: 'workload_item_updated', item }); +}); + +app.delete('/api/workload/items/:id', (req, res) => { + const ok = workloadStore.delete(req.params.id); + if (!ok) { + res.status(404).json({ error: 'Item not found' }); + return; + } + res.json({ ok: true }); +}); + +app.post('/api/workload/items/:id/promote', (req, res) => { + const body = WorkloadPromoteBody.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: body.error.issues[0]?.message ?? 'Invalid promote body' }); + return; + } + + const item = workloadStore.get(req.params.id); + if (!item) { + res.status(404).json({ error: 'Item not found' }); + return; + } + + // Build description from item context + const descParts: string[] = []; + if (body.data.description) descParts.push(body.data.description); + if (item.contextHints.taskHint) descParts.push(item.contextHints.taskHint); + const hintsWithValues = Object.entries(item.contextHints) + .filter(([k, v]) => k !== 'taskHint' && Array.isArray(v) && v.length > 0) + .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`); + if (hintsWithValues.length > 0) descParts.push(hintsWithValues.join('\n')); + + // Create root task (goal) from item + const task = taskStore.create({ + title: item.title, + description: descParts.join('\n\n') || undefined, + annotations: item.sources.map((s) => `Source: [${s.sourceType}] ${s.title} — ${s.url}`), + }); + + // Link item to goal + workloadStore.setGoalId(item.id, task.id); + + const updatedItem = workloadStore.get(item.id); + res.status(201).json({ task, item: updatedItem }); + onTaskBroadcast?.({ type: 'task_state', tasks: taskStore.getTree() }); + onWorkloadBroadcast?.({ type: 'workload_item_updated', item: updatedItem }); +}); + // --- Static files --- const frontendDist = join(__dirname, '..', 'frontend', 'dist'); diff --git a/server/chat.ts b/server/chat.ts index ba282001..fec00f44 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -30,7 +30,7 @@ import { loadProjectHooks } from './hook-bridge.js'; import { buildPermissionHandler } from './permission-handler.js'; import { runQueryLoop, broadcastToObservers } from './query-loop.js'; import { AsyncQueue } from './async-queue.js'; -import { GIT_BRANCH_TIMEOUT_MS, SESSION_PAGE_SIZE, SESSION_MESSAGES_LIMIT } from './constants.js'; +import { GIT_BRANCH_TIMEOUT_MS, SESSION_PAGE_SIZE, SESSION_MESSAGES_LIMIT, USER_CLOSEOUT_TIMEOUT_MS } from './constants.js'; import { INTERNAL_TOKEN } from './internal-token.js'; import { buildTaskSystemPrompt } from './task-context.js'; import type { TaskStore } from './task-store.js'; @@ -44,6 +44,12 @@ let _connRegistry: ConnectionRegistry | null = null; export function setConnectionRegistry(registry: ConnectionRegistry): void { _connRegistry = registry; } + +type SessionChangeCallback = (clientId: string, event: 'start' | 'end' | 'turn_end') => void; +let _onSessionChange: SessionChangeCallback | null = null; +export function setSessionChangeCallback(cb: SessionChangeCallback): void { + _onSessionChange = cb; +} import { EventStore } from './event-store.js'; import { capturePromptComparison } from './prompt-compare.js'; import { shouldAutoRename, extractRecentPrompts, generateSessionName } from './auto-rename.js'; @@ -553,6 +559,7 @@ export async function startChat( contextBlocks?: string[]; clientMsgId?: string; onSessionResolved?: (sessionId: string) => void; + telosTaskId?: string; }, ) { return withSpanAsync( @@ -581,6 +588,7 @@ async function _startChatInner( contextBlocks?: string[]; clientMsgId?: string; onSessionResolved?: (sessionId: string) => void; + telosTaskId?: string; }, ) { const abortController = new AbortController(); @@ -656,10 +664,14 @@ async function _startChatInner( wtId, sessionAllowList: new Set(), worktreePath, + // Set sessionId early so pre-assistant events are persisted (iOS reconnect). + ...(options.resume ? { sessionId: options.resume } : {}), + ...(options.telosTaskId ? { telosTaskId: options.telosTaskId } : {}), }); const session = registry.get(clientId)!; session.inputQueue = inputQueue as { push: (msg: unknown) => void; close: () => void }; + _onSessionChange?.(clientId, 'start'); // Copy all repo worktrees into the session for cleanup tracking for (const [name, info] of repoWorktrees) { @@ -679,6 +691,7 @@ async function _startChatInner( mode, branch, ...(worktreePath ? { wtId } : {}), + ...(options.telosTaskId ? { telosTaskId: options.telosTaskId } : {}), }); } @@ -715,7 +728,84 @@ async function _startChatInner( buildWorktreeSystemPrompt(repoWorktrees) + buildTaskPromptForSession(clientId); - // Fire-and-forget: capture prompt comparison for the experiments spoke + // Fire-and-forget: emit boot context metadata to client + capture prompt comparison + (async () => { + // Step 1: dynamically import contexgin (optional dependency) + let compileModule: { + compile: (opts: { workspaceRoot: string; tokenBudget: number }) => Promise; + }; + try { + compileModule = await import('contexgin'); + } catch (importErr: unknown) { + const msg = importErr instanceof Error ? importErr.message : String(importErr); + log.info('contexgin not available, using fallback', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + // Step 2: compile — runtime errors propagate (not swallowed as import failure) + try { + const compiled = await compileModule.compile({ workspaceRoot: cwd, tokenBudget: 8000 }); + + // Validate the compiled object shape + if (!compiled || typeof compiled !== 'object') { + log.warn('contexgin compile() returned unexpected shape', { compiled }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + const obj = compiled as Record; + const sources = Array.isArray(obj.sources) ? obj.sources : []; + const trimmed = Array.isArray(obj.trimmed) ? obj.trimmed : []; + const bootTokens = typeof obj.bootTokens === 'number' ? obj.bootTokens : 0; + + // Validate each source entry has a relativePath string + const sourcePaths: string[] = []; + for (const s of sources) { + if ( + s && + typeof s === 'object' && + typeof (s as Record).relativePath === 'string' + ) { + sourcePaths.push((s as Record).relativePath as string); + } + } + + send(transport, { + type: 'boot_context', + source: 'contexgin', + sourceCount: sourcePaths.length, + tokenCount: bootTokens, + trimmedCount: trimmed.length, + sources: sourcePaths, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.warn('boot context compilation failed', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + } + })(); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); try { @@ -779,6 +869,9 @@ async function _startChatInner( /* errors logged internally */ }); }, + onTurnEnd: (cId: string) => { + _onSessionChange?.(cId, 'turn_end'); + }, }, ); } catch (err: unknown) { @@ -797,8 +890,7 @@ async function _startChatInner( if (failedSession) cleanupSessionWorktrees(failedSession); registry.abort(clientId); } finally { - // Phase 2e: worktrees survive until explicit close or stale GC. - // Only failed sessions (caught above) clean up their worktrees. + _onSessionChange?.(clientId, 'end'); } } @@ -914,6 +1006,17 @@ export async function interruptChat( const echo = { type: 'user_message', messageId, text: fullPrompt }; send(session.transport, echo); broadcastToObservers(session.observers, echo); + // Stop all active subagent tasks before interrupting the parent query. + // Without this, interrupt() only halts the parent — which is blocked + // waiting for the subagent, so the session hangs. + if (session.activeTaskIds.size > 0) { + const stops = [...session.activeTaskIds.keys()].map((taskId) => + session + .queryInstance!.stopTask(taskId) + .catch((err: unknown) => log.warn('stopTask failed', { taskId, err })), + ); + await Promise.allSettled(stops); + } await session.queryInstance.interrupt(); session.inputQueue.push(makeUserMessage(fullPrompt, 'now')); return true; @@ -978,6 +1081,7 @@ function _closeoutSessionInner(clientId: string): void { try { finalizeCloseout(BASE_REPO, session.wtId, { status: 'abandoned', + closed_by: 'abandoned', tokens_used: session.cumulativeSessionTokens, cost_usd: session.cumulativeCostUsd, }); @@ -1002,9 +1106,15 @@ function _closeoutSessionInner(clientId: string): void { const wtId = session.wtId; const onAbort = () => { const status = registry.isClosingOut(clientId) ? 'abandoned' : 'closed'; + const closedBy = registry.isUserClose(clientId) + ? 'user' + : status === 'abandoned' + ? 'abandoned' + : 'auto'; try { finalizeCloseout(BASE_REPO, wtId, { status, + closed_by: closedBy, tokens_used: session.cumulativeSessionTokens, cost_usd: session.cumulativeCostUsd, }); @@ -1019,6 +1129,86 @@ function _closeoutSessionInner(clientId: string): void { // Wire closeout handler on the registry registry.setCloseoutHandler(closeoutSession); +const USER_CLOSEOUT_PROMPT = `The user has closed this session. +Please perform session closeout: + +1. If there is uncommitted work in any worktree, commit it now with a descriptive message +2. If there are memory-worthy observations, decisions, or patterns — write them to memory/Observations/ or memory/Decisions/ +3. Write a 2-3 sentence summary of what was accomplished and what remains unfinished — output it as your final chat message so it appears in the conversation history +4. Do not ask for confirmation — just do it`; + +/** + * User-initiated session close. Triggers the same closeout flow as + * auto-close but with a shorter timeout (2 minutes) and marks the + * session as closed by the user. + */ +export function closeSessionByUser(clientId: string): void { + withSpan('session.close_by_user', { 'session.clientId': clientId }, () => { + const session = registry.get(clientId); + if (!session) return; + + // Mark as user-initiated close in the registry + registry.markUserClose(clientId); + + if (!session.inputQueue) { + // No active agent — finalize immediately + if (session.wtId) { + try { + finalizeCloseout(BASE_REPO, session.wtId, { + status: 'closed', + closed_by: 'user', + tokens_used: session.cumulativeSessionTokens, + cost_usd: session.cumulativeCostUsd, + }); + } catch { + // best-effort + } + } + if (session.sessionId) { + eventStore.upsertSession({ sessionId: session.sessionId, isActive: false, closedBy: 'user' }); + } + registry.remove(clientId); + return; + } + + log.info('user-initiated closeout', { clientId, wtId: session.wtId }); + + // Inject closeout prompt + session.inputQueue.push(makeUserMessage(USER_CLOSEOUT_PROMPT, 'now')); + + // Register abort listener to finalize with closed_by: 'user' + if (session.wtId) { + const wtId = session.wtId; + const onAbort = () => { + try { + finalizeCloseout(BASE_REPO, wtId, { + status: 'closed', + closed_by: 'user', + tokens_used: session.cumulativeSessionTokens, + cost_usd: session.cumulativeCostUsd, + }); + } catch { + // best-effort + } + }; + session.abortController.signal.addEventListener('abort', onAbort, { once: true }); + } + + // Mark inactive in event store + if (session.sessionId) { + eventStore.upsertSession({ sessionId: session.sessionId, closedBy: 'user' }); + } + + // Set a shorter timeout — 2 minutes instead of 10 + setTimeout(() => { + if (registry.isActive(clientId) && registry.isUserClose(clientId)) { + log.info('user closeout timeout, aborting', { clientId }); + registry.abort(clientId); + } + }, USER_CLOSEOUT_TIMEOUT_MS); + }); +} + export function stopChat(clientId: string) { withSpan('session.stop', { 'session.clientId': clientId }, () => { const session = registry.get(clientId); @@ -1440,9 +1630,14 @@ export function replayEventsToMessages( // Inject the initial prompt as the first message. // Priority: initialPrompt param (from session metadata) > legacy out-of-order event if (initialPrompt) { + // Reuse the stored messageId so REST recovery and WS replay agree on IDs + const matchingEvt = events.find( + (e) => e.type === 'user_message' && e.payload.text === initialPrompt, + ); + const messageId = matchingEvt ? (matchingEvt.payload.messageId as string) : 'umsg-initial'; const firstTs = events[0]?.createdAt; messages.push({ - messageId: `umsg-initial-${Date.now()}`, + messageId, role: 'user', timestamp: firstTs, blocks: [{ blockId: 'user-initial', blockType: 'text', content: initialPrompt }], diff --git a/server/constants.ts b/server/constants.ts index 1bb55aa9..744c94b2 100644 --- a/server/constants.ts +++ b/server/constants.ts @@ -20,6 +20,7 @@ export { DETACHED_TTL_MS, CLOSEOUT_LEAD_MS, CLOSEOUT_TIMEOUT_MS, + USER_CLOSEOUT_TIMEOUT_MS, PERMISSION_TIMEOUT_MS, NTFY_NOTIFICATION_DELAY_MS, } from '@mitzo/harness'; @@ -46,3 +47,10 @@ export const WORKTREE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours // as unreachable (e.g. model unavailable on the configured provider) and // surface an error to the client instead of hanging forever. export const QUERY_FIRST_EVENT_TIMEOUT_MS = 90_000; + +// --- Observability --- +// Max characters for agent content recorded in OTel span events and log lines. +// Keeps Jaeger/Loki payloads bounded while preserving enough for debugging. +// Configurable via env var for runtime tuning without code changes. +export const TRACE_CONTENT_MAX_CHARS = + parseInt(process.env.TRACE_CONTENT_MAX_CHARS ?? '', 10) || 16_384; diff --git a/server/health-monitor.ts b/server/health-monitor.ts new file mode 100644 index 00000000..d07968e4 --- /dev/null +++ b/server/health-monitor.ts @@ -0,0 +1,110 @@ +// Periodic health monitor for upstream services (Yapper, ContexGin). +// Broadcasts a `health` SSE event when status changes. + +import type { SseRegistry } from '@mitzo/harness'; +import type { ServiceHealthPayload, ServiceHealthStatus } from '@mitzo/protocol'; +import { createLogger } from './logger.js'; + +const log = createLogger('health-monitor'); + +const POLL_INTERVAL_MS = 30_000; +const CHECK_TIMEOUT_MS = 3_000; + +interface ServiceCheck { + name: string; + url: string; + parseDetail?: (data: unknown) => Record | undefined; +} + +const SERVICES: ServiceCheck[] = [ + { + name: 'yapper', + url: process.env.YAPPER_PROXY_TARGET || 'http://localhost:8700', + parseDetail: (data) => { + const d = data as { models?: { stt?: boolean; tts?: boolean } }; + return d.models ? { stt: !!d.models.stt, tts: !!d.models.tts } : undefined; + }, + }, + { + name: 'contexgin', + url: process.env.CONTEXGIN_URL || 'http://localhost:8321', + }, +]; + +export class HealthMonitor { + private sseRegistry: SseRegistry; + private timer: ReturnType | null = null; + private lastPayload: ServiceHealthPayload | null = null; + + constructor(sseRegistry: SseRegistry) { + this.sseRegistry = sseRegistry; + } + + start(): void { + if (this.timer) return; + log.info('Starting health monitor', { intervalMs: POLL_INTERVAL_MS }); + // Initial check immediately + this.check(); + this.timer = setInterval(() => this.check(), POLL_INTERVAL_MS); + } + + getSnapshot(): ServiceHealthPayload { + return this.lastPayload ?? { services: [], checkedAt: 0 }; + } + + destroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + log.info('Health monitor destroyed'); + } + + private async check(): Promise { + const results = await Promise.all(SERVICES.map((s) => this.checkService(s))); + const payload: ServiceHealthPayload = { + services: results, + checkedAt: Date.now(), + }; + + if (this.hasChanged(payload)) { + log.info('Service health changed', { + services: results.map((s) => `${s.name}=${s.ok ? 'ok' : 'down'}`).join(', '), + }); + this.lastPayload = payload; + this.sseRegistry.broadcast('health', payload); + } else { + this.lastPayload = payload; + } + } + + private async checkService(service: ServiceCheck): Promise { + try { + const res = await fetch(`${service.url}/health`, { + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); + if (!res.ok) return { name: service.name, ok: false }; + + const data = await res.json(); + const isReady = data.status === 'ready' || data.status === 'ok' || data.status === 'healthy'; + return { + name: service.name, + ok: isReady, + detail: service.parseDetail?.(data), + }; + } catch { + return { name: service.name, ok: false }; + } + } + + private hasChanged(next: ServiceHealthPayload): boolean { + if (!this.lastPayload) return true; + const prev = this.lastPayload.services; + if (prev.length !== next.services.length) return true; + for (let i = 0; i < prev.length; i++) { + if (prev[i].ok !== next.services[i].ok) return true; + if (JSON.stringify(prev[i].detail) !== JSON.stringify(next.services[i].detail)) return true; + } + return false; + } +} diff --git a/server/index.ts b/server/index.ts index c77ce299..2e7a1ca5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,7 @@ import { eventStore, getRepoConfig, setConnectionRegistry, + setSessionChangeCallback, reconcileSessionsBackground, } from './chat.js'; import { cleanupStaleWorktrees, countWorktrees } from './worktree.js'; @@ -42,22 +43,29 @@ import { import { createLogger } from './logger.js'; import { app, + sseRegistry, setUpdateBroadcast, setInboxBroadcast, setTaskBroadcast, + setWorkloadBroadcast, setOrchestrator, setTemplateStore, setSignalProcessor, + setOverviewEmitter, + setHealthMonitor, runUpdateCheck, buildSkillRegistry, NATIVE_COMMAND_NAMES, isAllowedPath, yapperWsProxy, taskStore, + workloadStore, } from './app.js'; import { WorkflowTemplateStore, seedBuiltInTemplates } from './workflow-templates.js'; import { SignalProcessor } from './signal-processor.js'; import { TaskOrchestrator } from './task-orchestrator.js'; +import { SessionOverviewEmitter } from './session-overview.js'; +import { HealthMonitor } from './health-monitor.js'; import { IncomingWsMessage } from './ws-schemas.js'; import { resolvePending } from './permissions.js'; import { resolveSlashCommand } from './slash-commands.js'; @@ -81,6 +89,14 @@ const nativeCommands = new NativeCommandRegistry(); const connRegistry = new ConnectionRegistry(); setConnectionRegistry(connRegistry); +// Wire up EventStore for periodic sync (enables delivery guarantee). +// Provide isSessionActive so periodic sync skips ended sessions. +connRegistry.setEventStore({ + getEventsAfter: (sessionId, afterSeq, limit) => + eventStore.getEventsAfter(sessionId, afterSeq, limit), + isSessionActive: (sessionId) => eventStore.getSession(sessionId)?.isActive ?? false, +}); + // Resolve cert paths relative to the project root (where package.json lives) const __filename = fileURLToPath(import.meta.url); const PROJECT_ROOT = join(__filename, '..', '..'); @@ -94,6 +110,10 @@ const server = USE_TLS : createServer(app); const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); +// Declared early so broadcast closures can reference it safely (assigned after orchestrator init) +// eslint-disable-next-line prefer-const +let overviewEmitter: SessionOverviewEmitter; + setUpdateBroadcast(() => { const data = { type: 'update_available' }; const msg = JSON.stringify(data); @@ -102,6 +122,7 @@ setUpdateBroadcast(() => { if (client.readyState === client.OPEN) client.send(msg); }); connRegistry.broadcastAll(data); + sseRegistry.broadcast('update_available', {}); }); setInboxBroadcast(() => { @@ -112,6 +133,7 @@ setInboxBroadcast(() => { if (client.readyState === client.OPEN) client.send(msg); }); connRegistry.broadcastAll(data); + sseRegistry.broadcast('inbox_updated', {}); }); setTaskBroadcast((event) => { @@ -121,6 +143,18 @@ setTaskBroadcast((event) => { if (client.readyState === client.OPEN) client.send(msg); }); connRegistry.broadcastAll(event as Record); + sseRegistry.broadcast('task_state', event); + overviewEmitter.scheduleBroadcast(); +}); + +setWorkloadBroadcast((event) => { + const msg = JSON.stringify(event); + wss.clients.forEach((client) => { + if (v2Sockets.has(client)) return; + if (client.readyState === client.OPEN) client.send(msg); + }); + connRegistry.broadcastAll(event as Record); + sseRegistry.broadcast('todo_update', { action: 'refresh' }); }); // --- Workflow layer --- @@ -139,6 +173,7 @@ setSignalProcessor(signalProc); // --- Task Orchestrator --- const orchestrator = new TaskOrchestrator({ store: taskStore, + workloadStore, watchSignal: (taskId, gateConfig) => signalProc.watch(taskId, gateConfig), getClientId: () => { // Find the first registered client (reuse-only for Phase 2) @@ -171,6 +206,8 @@ const orchestrator = new TaskOrchestrator({ if (client.readyState === client.OPEN) client.send(msg); }); connRegistry.broadcastAll(data); + sseRegistry.broadcast('loop_status', status); + overviewEmitter.scheduleBroadcast(); }, broadcastTasks: () => { const tree = taskStore.getTree(); @@ -181,6 +218,7 @@ const orchestrator = new TaskOrchestrator({ if (client.readyState === client.OPEN) client.send(msg); }); connRegistry.broadcastAll(data as Record); + sseRegistry.broadcast('task_state', data); }, getActiveSessionIds: () => { const ids = new Set(); @@ -194,6 +232,33 @@ const orchestrator = new TaskOrchestrator({ orchestratorRef = orchestrator; setOrchestrator(orchestrator); +// --- Session Overview Emitter (SSE broadcast) --- +overviewEmitter = new SessionOverviewEmitter({ + registry, + sseRegistry, + getLoopStatus: () => orchestrator.getStatus(), + taskStore, + getSessionTitle: (id: string) => eventStore.getSession(id)?.summary ?? undefined, +}); +setOverviewEmitter(overviewEmitter); + +// --- Health Monitor (SSE broadcast) --- +const healthMonitor = new HealthMonitor(sseRegistry); +setHealthMonitor(healthMonitor); +healthMonitor.start(); + +// Hook session lifecycle events into the overview emitter +setSessionChangeCallback((clientId, event) => { + if (event === 'start') { + overviewEmitter.touch(clientId); + } else if (event === 'end') { + overviewEmitter.forget(clientId); + } else if (event === 'turn_end') { + overviewEmitter.touch(clientId); + } + overviewEmitter.scheduleBroadcast(); +}); + server.on('upgrade', async (req, socket, head) => { const url = new URL(req.url || '', `http://${req.headers.host}`); @@ -332,6 +397,8 @@ function handleChatWsV2(ws: WebSocket, connectionId: string) { { 'session.sessionId': sessionId, 'ws.connectionId': connectionId }, () => { detachChat(found.clientId); + overviewEmitter.touch(found.clientId); + overviewEmitter.scheduleBroadcast(); log.info('v2 session detached (surviving)', { connectionId, sessionId }); }, ); @@ -751,6 +818,8 @@ function handleChatWs( (span) => { if (isActive(clientId)) { detachChat(clientId); + overviewEmitter.touch(clientId); + overviewEmitter.scheduleBroadcast(); span.setAttribute('ws.disconnect.detached', true); log.info('session detached (surviving)', { clientId }); } @@ -768,6 +837,10 @@ function shutdown(signal: string) { server.close(); signalProc.unwatchAll(); wfTemplateStore.close(); + healthMonitor.destroy(); + overviewEmitter.destroy(); + sseRegistry.destroy(); + connRegistry.dispose(); // Stop periodic sync + clear state registry.dispose(); for (const client of wss.clients) { client.close(1001, 'Server shutting down'); @@ -787,9 +860,22 @@ checkPort(PORT).then((inUse) => { process.exit(1); } + // Plain HTTP listener for watchOS (can't trust self-signed TLS certs) + if (USE_TLS) { + const httpServer = createServer(app); + const HTTP_PORT = PORT + 1; + httpServer.listen(HTTP_PORT, () => { + log.info(`HTTP listener for watchOS on http://localhost:${HTTP_PORT}`); + }); + } + server.listen(PORT, () => { const protocol = USE_TLS ? 'https' : 'http'; log.info(`Chat Agent running on ${protocol}://localhost:${PORT}${USE_TLS ? ' (TLS)' : ''}`); + + // Start periodic sync for connection-level delivery guarantee + connRegistry.startPeriodicSync(); + // Eagerly reconcile sessions so the first /api/sessions request is fast and accurate. reconcileSessionsBackground(); // Clean up stale worktrees across all repos. diff --git a/server/native-commands.ts b/server/native-commands.ts index 82ffb227..51e8ca03 100644 --- a/server/native-commands.ts +++ b/server/native-commands.ts @@ -12,6 +12,7 @@ export class NativeCommandRegistry { constructor() { this.commands.set('skills', skillsCommand); + this.commands.set('close', closeCommand); } has(name: string): boolean { @@ -81,3 +82,12 @@ function skillsCommand(args: string, skillRegistry: SkillRegistry): NativeComman return { command: 'skills', content: lines.join('\n') }; } + +// --- /close command --- + +function closeCommand(): NativeCommandResult { + return { + command: 'close', + content: 'Closing session... The agent will commit any uncommitted work and write a summary.', + }; +} diff --git a/server/query-loop.ts b/server/query-loop.ts index b32989a7..e18da8b2 100644 --- a/server/query-loop.ts +++ b/server/query-loop.ts @@ -5,6 +5,7 @@ import { TOOL_RESULT_MAX_CHARS, CONTEXT_CEILING_TOKENS, QUERY_FIRST_EVENT_TIMEOUT_MS, + TRACE_CONTENT_MAX_CHARS, } from './constants.js'; import { createLogger } from './logger.js'; import type { SessionRegistry, SnapshotBlock } from './session-registry.js'; @@ -21,6 +22,58 @@ import { context, trace, SpanStatusCode, type Span } from '@opentelemetry/api'; import { ProgressTracker } from './progress-tracker.js'; const log = createLogger('query-loop'); +/** Truncate text for trace/log payloads, returning a truncated flag when clipped. */ +function truncateForTrace(text: string): { text: string; truncated?: true } { + if (text.length <= TRACE_CONTENT_MAX_CHARS) return { text }; + return { text: text.slice(0, TRACE_CONTENT_MAX_CHARS), truncated: true }; +} + +/** End all open OTel spans for a subagent entry. */ +function endSubagentSpans( + sub: { + span: Span | null; + turnSpan: Span | null; + toolSpans: Map; + startedAt: number; + usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + } | null; + }, + statusCode: typeof SpanStatusCode.OK | typeof SpanStatusCode.ERROR, +): void { + // End any open tool spans + for (const [, ts] of sub.toolSpans) { + ts.setStatus({ code: statusCode }); + ts.end(); + } + sub.toolSpans.clear(); + + // End turn span + if (sub.turnSpan) { + sub.turnSpan.setStatus({ code: statusCode }); + sub.turnSpan.end(); + } + + // End subagent span with attributes + if (sub.span) { + const durationMs = Date.now() - sub.startedAt; + sub.span.setAttribute('subagent.duration_ms', durationMs); + if (sub.usage) { + const totalTokens = + sub.usage.inputTokens + + sub.usage.outputTokens + + sub.usage.cacheReadTokens + + sub.usage.cacheCreationTokens; + sub.span.setAttribute('subagent.total_tokens', totalTokens); + } + sub.span.setStatus({ code: statusCode }); + sub.span.end(); + } +} + /** Send data via transport, guarding on isOpen(). */ function send(transport: SessionTransport, data: Record) { if (transport.isOpen()) transport.send(data); @@ -126,6 +179,8 @@ export interface QueryLoopOptions { onSessionResolved?: (sessionId: string) => void; /** Called after the initial prompt is registered, enabling auto-rename on prompt 1. */ onInitialPrompt?: (sessionId: string) => void; + /** Called when an assistant turn completes (snapshot cleared). */ + onTurnEnd?: (clientId: string) => void; } export async function runQueryLoop( @@ -171,10 +226,39 @@ async function _runQueryLoopInner( // Progress tracker — intercepts TodoWrite calls, emits structured events. const progressTracker = new ProgressTracker(); + // Subagent tracking — maps parent_tool_use_id → subagent state + const activeSubagents = new Map< + string, + { + parentBlockId: string; + parentToolName: string; + taskId: string | null; + subagentMessageId: string | null; + subagentBlockIdByIndex: Map; + subagentToolInputBuffers: Map; + usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + } | null; + // OTel span hierarchy for subagent + span: Span | null; + turnSpan: Span | null; + turnIndex: number; + toolSpans: Map; // blockId → tool span + startedAt: number; + } + >(); + // Map content block index → blockId for all block types. const blockIdByIndex = new Map(); + // Persistent map: toolId → blockId (for subagent parent lookup) + const toolIdToBlockId = new Map(); + let blockCounter = 0; + let caughtError = false; let currentMessageId: string | null = null; let doneSent = false; let openBlockCount = 0; @@ -209,11 +293,31 @@ async function _runQueryLoopInner( durationApiMs: 0, }; + // Buffer v2 events emitted before sessionId is known (first turn of new + // sessions). Once the sessionId resolves, these are flushed to the durable + // store so reconnecting clients can reconstruct the full message. + const preSessionBuffer: Record[] = []; + /** Wrapper that auto-injects store + sessionId + connRegistry into sendOrBuffer */ function emit(data: Record) { + if (!resolvedSessionId && data.v === 2) { + preSessionBuffer.push(data); + } sendOrBuffer(data, clientId, registry, store, resolvedSessionId, connRegistry); } + /** Flush buffered pre-sessionId events to the durable store. */ + function flushPreSessionBuffer() { + if (!store || !resolvedSessionId || preSessionBuffer.length === 0) return; + for (const event of preSessionBuffer) { + store.append(resolvedSessionId, event.type as string, { + ...event, + sessionId: resolvedSessionId, + }); + } + preSessionBuffer.length = 0; + } + function nextBlockId(): string { return `b${blockCounter++}`; } @@ -231,6 +335,7 @@ async function _runQueryLoopInner( currentTurnSpan.end(); currentTurnSpan = null; } + options?.onTurnEnd?.(clientId); } } @@ -307,6 +412,7 @@ async function _runQueryLoopInner( if (!currentSession) break; if (!resolvedSessionId && currentSession.sessionId) { resolvedSessionId = currentSession.sessionId; + flushPreSessionBuffer(); // Resumed sessions: load goalId from store so usage reporting works if (store && !resolvedGoalId) { const existingSession = store.getSession(resolvedSessionId); @@ -323,6 +429,34 @@ async function _runQueryLoopInner( log.debug('sdk event', { clientId, type: msg.type }); if (msg.type === 'assistant') { + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + + // Subagent turn complete — emit subagent_end + close spans + if (parentToolUseId) { + const subagent = activeSubagents.get(parentToolUseId); + if (subagent) { + const durationMs = Date.now() - subagent.startedAt; + endSubagentSpans(subagent, SpanStatusCode.OK); + + emit( + v2('subagent_end', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + usage: subagent.usage ?? undefined, + }), + ); + log.info('subagent completed', { + clientId, + parentToolId: parentToolUseId, + subagentMessageId: subagent.subagentMessageId, + usage: subagent.usage, + durationMs, + }); + activeSubagents.delete(parentToolUseId); + } + continue; + } + // Turn complete — defer message_end until all blocks are closed. if (currentMessageId) { pendingMessageEnd = v2('message_end', { @@ -334,6 +468,7 @@ async function _runQueryLoopInner( // Capture session ID on first assistant event. if (!currentSession.sessionId && msg.session_id) { resolvedSessionId = msg.session_id as string; + flushPreSessionBuffer(); span.setAttribute('session.id', resolvedSessionId); registry.setSessionId(clientId, resolvedSessionId); onSessionResolved?.(resolvedSessionId); @@ -356,6 +491,7 @@ async function _runQueryLoopInner( branch: currentSession.branch, ...(currentSession.worktreePath ? { wtId: currentSession.wtId } : {}), ...(initialPrompt ? { initialPrompt } : {}), + ...(currentSession.telosTaskId ? { telosTaskId: currentSession.telosTaskId } : {}), }); if (initialPrompt) { // Store the initial prompt as a user_message event so @@ -500,6 +636,103 @@ async function _runQueryLoopInner( log.debug('stream event', { clientId, evtType: evt?.type }); if (evt?.type === 'message_start') { + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + + // Subagent message_start — emit subagent_start instead of normal message_start + if (parentToolUseId) { + const parentBlockId = toolIdToBlockId.get(parentToolUseId); + const apiMsg = evt.message as Record | undefined; + const subagentMessageId = (apiMsg?.id as string | undefined) ?? `msg-${Date.now()}`; + + if (parentBlockId) { + const existing = activeSubagents.get(parentToolUseId); + + if (existing) { + // Multi-turn subagent: end previous turn span, start a new one + if (existing.turnSpan) { + existing.turnSpan.setStatus({ code: SpanStatusCode.OK }); + existing.turnSpan.end(); + } + existing.turnIndex += 1; + existing.subagentMessageId = subagentMessageId; + existing.subagentBlockIdByIndex.clear(); + existing.subagentToolInputBuffers.clear(); + + const subTurnCtx = existing.span + ? trace.setSpan(context.active(), existing.span) + : context.active(); + existing.turnSpan = tracer.startSpan('subagent.turn', {}, subTurnCtx); + existing.turnSpan.setAttribute('subagent.turn.index', existing.turnIndex); + } else { + // First turn: create subagent span under the parent tool span + const parentToolSpan = toolSpans.get(parentBlockId); + if (!parentToolSpan) { + log.warn('subagent parent tool span not found', { + parentBlockId, + parentToolUseId, + }); + } + const subagentParentCtx = parentToolSpan + ? trace.setSpan(context.active(), parentToolSpan) + : context.active(); + const subagentSpan = tracer.startSpan('subagent', {}, subagentParentCtx); + subagentSpan.setAttribute('subagent.parent_tool_id', parentToolUseId); + subagentSpan.setAttribute('subagent.message_id', subagentMessageId); + + // Start first turn span under the subagent span + const subTurnCtx = trace.setSpan(context.active(), subagentSpan); + const subTurnSpan = tracer.startSpan('subagent.turn', {}, subTurnCtx); + subTurnSpan.setAttribute('subagent.turn.index', 0); + + activeSubagents.set(parentToolUseId, { + parentBlockId, + parentToolName: 'Agent', + taskId: null, + subagentMessageId, + subagentBlockIdByIndex: new Map(), + subagentToolInputBuffers: new Map(), + usage: null, + span: subagentSpan, + turnSpan: subTurnSpan, + turnIndex: 0, + toolSpans: new Map(), + startedAt: Date.now(), + }); + } + + // Extract usage from message_start + const msgUsage = (apiMsg as Record | undefined)?.usage as + | Record + | undefined; + if (msgUsage) { + activeSubagents.get(parentToolUseId)!.usage = { + inputTokens: msgUsage.input_tokens ?? 0, + outputTokens: msgUsage.output_tokens ?? 0, + cacheReadTokens: msgUsage.cache_read_input_tokens ?? 0, + cacheCreationTokens: msgUsage.cache_creation_input_tokens ?? 0, + }; + } + + if (!existing) { + emit( + v2('subagent_start', { + parentBlockId, + parentToolId: parentToolUseId, + subagentMessageId, + }), + ); + } + log.info(existing ? 'subagent new turn' : 'subagent started', { + clientId, + parentToolId: parentToolUseId, + subagentMessageId, + ...(existing ? { turnIndex: existing.turnIndex } : {}), + }); + } + // Don't process parent turn logic for subagent message_start + continue; + } + // End previous turn span if still open (e.g. deferred message_end) if (currentTurnSpan) { currentTurnSpan.setAttribute('turn.block_count', turnBlockCount); @@ -563,6 +796,73 @@ async function _runQueryLoopInner( } } } else if (evt?.type === 'content_block_start') { + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + const subagent = parentToolUseId ? activeSubagents.get(parentToolUseId) : undefined; + + // Subagent content_block_start + if (subagent) { + const contentBlock = evt.content_block as Record | undefined; + const index = evt.index as number; + const blockId = nextBlockId(); + const blockType = contentBlock?.type as string | undefined; + subagent.subagentBlockIdByIndex.set(index, { + blockId, + blockType: blockType ?? 'text', + }); + + if (blockType === 'thinking' || blockType === 'redacted_thinking') { + emit( + v2('subagent_block_start', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType, + }), + ); + } else if (blockType === 'tool_use') { + const toolName = contentBlock!.name as string; + const toolId = contentBlock!.id as string; + subagent.subagentToolInputBuffers.set(index, { + name: toolName, + id: toolId, + inputBuf: '', + }); + + // Create OTel span for subagent tool under its turn span + const subToolParent = subagent.turnSpan + ? trace.setSpan(context.active(), subagent.turnSpan) + : context.active(); + const subToolSpan = tracer.startSpan( + `subagent.tool.${toolName}`, + {}, + subToolParent, + ); + subToolSpan.setAttribute('tool.name', toolName); + subToolSpan.setAttribute('tool.id', toolId); + subagent.toolSpans.set(blockId, subToolSpan); + + emit( + v2('subagent_block_start', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: 'tool_use', + toolName, + }), + ); + } else if (blockType === 'text') { + emit( + v2('subagent_block_start', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: 'text', + }), + ); + } + continue; + } + // Auto-init message context if SDK delivers blocks before message_start. // On the first turn, AssistantMessage can win the async iterator race // and the first content_block_start arrives before message_start. @@ -600,6 +900,7 @@ async function _runQueryLoopInner( const toolName = contentBlock!.name as string; const toolId = contentBlock!.id as string; toolInputBuffers.set(index, { name: toolName, id: toolId, inputBuf: '', blockId }); + toolIdToBlockId.set(toolId, blockId); // Track toolId → blockId for subagent lookup const snapshotBlock: SnapshotBlock = { blockId, blockType: 'tool_use', @@ -644,6 +945,43 @@ async function _runQueryLoopInner( ); } } else if (evt?.type === 'content_block_delta') { + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + const subagent = parentToolUseId ? activeSubagents.get(parentToolUseId) : undefined; + + // Subagent content_block_delta + if (subagent) { + const delta = evt.delta as Record | undefined; + const index = evt.index as number; + const blockEntry = subagent.subagentBlockIdByIndex.get(index); + const blockId = blockEntry?.blockId; + + if (delta?.type === 'text_delta' && blockId) { + emit( + v2('subagent_block_delta', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: 'text', + delta: delta.text as string, + }), + ); + } else if (delta?.type === 'thinking_delta' && blockId) { + emit( + v2('subagent_block_delta', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: 'thinking', + delta: delta.thinking as string, + }), + ); + } else if (delta?.type === 'input_json_delta') { + const entry = subagent.subagentToolInputBuffers.get(index); + if (entry) entry.inputBuf += delta.partial_json as string; + } + continue; + } + const delta = evt.delta as Record | undefined; const index = evt.index as number; const blockId = blockIdByIndex.get(index); @@ -683,6 +1021,67 @@ async function _runQueryLoopInner( if (entry) entry.inputBuf += delta.partial_json as string; } } else if (evt?.type === 'content_block_stop') { + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + const subagent = parentToolUseId ? activeSubagents.get(parentToolUseId) : undefined; + + // Subagent content_block_stop + if (subagent) { + const index = evt.index as number; + const blockEntry = subagent.subagentBlockIdByIndex.get(index); + const blockId = blockEntry?.blockId; + const toolEntry = subagent.subagentToolInputBuffers.get(index); + + if (toolEntry && blockId) { + subagent.subagentToolInputBuffers.delete(index); + let toolInput: Record = {}; + try { + toolInput = JSON.parse(toolEntry.inputBuf || '{}'); + } catch { + /* malformed JSON */ + } + const summarized = summarizeToolInput(toolEntry.name, toolInput); + const rawInput = getRawInput(toolEntry.name, toolInput); + + emit( + v2('subagent_block_end', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: 'tool_use', + toolName: toolEntry.name, + toolId: toolEntry.id, + input: summarized, + ...(rawInput ? { rawInput } : {}), + }), + ); + // End subagent tool span + const subToolSpan = subagent.toolSpans.get(blockId); + if (subToolSpan) { + subToolSpan.setStatus({ code: SpanStatusCode.OK }); + subToolSpan.end(); + subagent.toolSpans.delete(blockId); + } + + log.info('subagent tool call', { + clientId, + parentToolId: parentToolUseId, + tool: toolEntry.name, + toolId: toolEntry.id, + }); + } else if (blockId) { + // Text or thinking block — use the stored blockType + emit( + v2('subagent_block_end', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + blockId, + blockType: blockEntry!.blockType as 'text' | 'thinking', + }), + ); + } + continue; + } + const index = evt.index as number; const blockId = blockIdByIndex.get(index); const toolEntry = toolInputBuffers.get(index); @@ -698,11 +1097,18 @@ async function _runQueryLoopInner( const summarized = summarizeToolInput(toolEntry.name, toolInput); const rawInput = getRawInput(toolEntry.name, toolInput); + const trInput = truncateForTrace(toolEntry.inputBuf); log.info('tool call', { clientId, tool: toolEntry.name, toolId: toolEntry.id }); + log.debug('tool call input', { clientId, toolId: toolEntry.id, input: trInput.text }); - // End tool span + // End tool span — record raw input before closing const toolSpan = toolSpans.get(blockId); if (toolSpan) { + toolSpan.addEvent('tool.input', { + 'tool.name': toolEntry.name, + 'tool.input': trInput.text, + ...(trInput.truncated ? { 'tool.input.truncated': true } : {}), + }); toolSpan.setStatus({ code: SpanStatusCode.OK }); toolSpan.end(); toolSpans.delete(blockId); @@ -742,13 +1148,33 @@ async function _runQueryLoopInner( } } } else if (blockId) { - // Text or thinking block — mark done in snapshot. + // Text or thinking block — mark done in snapshot, record in traces + logs. const block = currentSession.currentSnapshot?.blocks.find( (b) => b.blockId === blockId, ); if (block) { const bt = block.blockType; block.done = true; + + // Record content in turn span and logs + if (block.content && (bt === 'thinking' || bt === 'text')) { + const tr = truncateForTrace(block.content); + if (currentTurnSpan) { + currentTurnSpan.addEvent(`block.${bt}`, { + 'block.id': blockId, + 'block.content': tr.text, + ...(tr.truncated ? { 'block.truncated': true } : {}), + }); + } + log.info(`${bt} block complete`, { + clientId, + blockId, + blockType: bt, + contentLength: block.content.length, + }); + log.debug(`${bt} block content`, { clientId, blockId, content: tr.text }); + } + emit( v2('block_end', { messageId: currentMessageId, @@ -763,13 +1189,49 @@ async function _runQueryLoopInner( tryFlushMessageEnd(currentSession); } } else if (msg.type === 'system') { - // Track compaction events from SDK system status messages const subtype = (msg as Record).subtype; + + // Track compaction events from SDK system status messages const compactResult = (msg as Record).compact_result; if (subtype === 'status' && compactResult === 'success') { numCompactions++; log.info('compaction completed', { clientId, numCompactions }); } + + // Track subagent task lifecycle for interrupt cancellation + if (subtype === 'task_started') { + const taskId = (msg as Record).task_id as string | undefined; + const toolUseId = (msg as Record).tool_use_id as string | undefined; + if (taskId && toolUseId) { + currentSession?.activeTaskIds.set(taskId, toolUseId); + const subagent = activeSubagents.get(toolUseId); + if (subagent) subagent.taskId = taskId; + log.info('subagent task started', { clientId, taskId, toolUseId }); + } else { + log.debug('task_started missing fields', { clientId, taskId, toolUseId }); + } + } else if (subtype === 'task_notification') { + const taskId = (msg as Record).task_id as string | undefined; + const status = (msg as Record).status as string | undefined; + const toolUseId = taskId ? currentSession?.activeTaskIds.get(taskId) : undefined; + if (taskId) { + currentSession?.activeTaskIds.delete(taskId); + log.info('subagent task finished', { clientId, taskId, status }); + } + if (status === 'stopped' && toolUseId) { + const subagent = activeSubagents.get(toolUseId); + if (subagent) { + emit( + v2('subagent_cancelled', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + taskId, + }), + ); + activeSubagents.delete(toolUseId); + } + } + } } else if (msg.type === 'user') { // Only extract tool_result events from SDK user turns. // Do NOT emit user_message here — human input is persisted at the @@ -778,10 +1240,54 @@ async function _runQueryLoopInner( // API conversation turns (agent sub-prompts, tool-result text) and // replay them as user bubbles on session rejoin. const content = (msg.message as unknown as Record)?.content; + const parentToolUseId = msg.parent_tool_use_id as string | undefined; + const subagent = parentToolUseId ? activeSubagents.get(parentToolUseId) : undefined; + if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_result') { const resultText = extractToolResultText(block.content); + const trResult = truncateForTrace(resultText); + + // Check if this is a subagent tool result + if (subagent) { + emit( + v2('subagent_tool_result', { + parentBlockId: subagent.parentBlockId, + subagentMessageId: subagent.subagentMessageId, + toolId: block.tool_use_id || '', + result: resultText.slice(0, TOOL_RESULT_MAX_CHARS), + isError: block.is_error === true, + }), + ); + log.info('subagent tool result', { + clientId, + parentToolId: parentToolUseId, + toolId: block.tool_use_id || '', + isError: block.is_error === true, + }); + continue; + } + + // Record tool result in session span and logs + span.addEvent('tool.result', { + 'tool.id': block.tool_use_id || '', + 'tool.result': trResult.text, + 'tool.is_error': block.is_error === true, + ...(trResult.truncated ? { 'tool.result.truncated': true } : {}), + }); + log.info('tool result', { + clientId, + toolId: block.tool_use_id || '', + isError: block.is_error === true, + resultLength: resultText.length, + }); + log.debug('tool result content', { + clientId, + toolId: block.tool_use_id || '', + result: trResult.text, + }); + emit( v2('tool_result', { messageId: currentMessageId, @@ -796,6 +1302,7 @@ async function _runQueryLoopInner( } } } catch (err: unknown) { + caughtError = true; span.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : 'unknown', @@ -819,14 +1326,11 @@ async function _runQueryLoopInner( if (finalSession) { finalSession.currentSnapshot = null; if (!doneSent) { - const endMsg = v2('session_end', { sessionId: finalSession.sessionId }); - const sid = finalSession.sessionId; - if (sid && connRegistry?.hasOpenWatchers(sid)) { - connRegistry.broadcast(sid, endMsg); - } else { - send(finalSession.transport, endMsg); - broadcastToObservers(finalSession.observers, endMsg); - } + // Use sendOrBuffer to persist to EventStore (not just send) so that + // reconnect replay includes session_end and clears stale running state. + const sid = finalSession.sessionId ?? resolvedSessionId; + const endMsg = v2('session_end', { sessionId: sid }); + sendOrBuffer(endMsg, clientId, registry, store, sid, connRegistry); if (sid && connRegistry?.hasOpenWatchers(sid)) { // Clear active session only on connections whose active is this session for (const { connectionId: cid } of connRegistry.getConnectionsWatching(sid, true)) { @@ -843,6 +1347,12 @@ async function _runQueryLoopInner( if (store && resolvedSessionId) { store.markSessionInactive(resolvedSessionId); } + // Clean up any open subagent spans (ERROR if catch was entered) + const subagentCleanupStatus = caughtError ? SpanStatusCode.ERROR : SpanStatusCode.OK; + for (const [, sub] of activeSubagents) { + endSubagentSpans(sub, subagentCleanupStatus); + } + activeSubagents.clear(); // Clean up any open tool spans for (const [, ts] of toolSpans) { ts.setStatus({ code: SpanStatusCode.OK }); diff --git a/server/session-index.ts b/server/session-index.ts index c1892ce7..61083e09 100644 --- a/server/session-index.ts +++ b/server/session-index.ts @@ -18,6 +18,7 @@ export interface SessionIndexEntry { last_title?: string; repos?: SessionIndexRepo[]; status?: 'active' | 'closed' | 'abandoned'; + closed_by?: 'user' | 'auto' | 'abandoned'; has_uncommitted?: boolean; closeout_summary?: string; tokens_used?: number; @@ -132,6 +133,7 @@ export function finalizeCloseout( wtId: string, fields: { status: 'closed' | 'abandoned'; + closed_by?: 'user' | 'auto' | 'abandoned'; tokens_used?: number; cost_usd?: number; has_uncommitted?: boolean; diff --git a/server/session-overview.ts b/server/session-overview.ts new file mode 100644 index 00000000..94ce8bf5 --- /dev/null +++ b/server/session-overview.ts @@ -0,0 +1,267 @@ +/** + * Session Overview — computes SessionActivity[] from server-side registries + * and broadcasts via SSE with coalescing. + * + * State is derived lazily at broadcast time — no stored state, no timers for + * timeout transitions. This avoids divergence across multiple clients. + */ + +import type { SessionRegistry, ActiveSessionInfo } from '@mitzo/harness'; +import type { SseRegistry } from '@mitzo/harness'; +import { getPendingCountBySession } from '@mitzo/harness'; +import type { SessionActivity, SessionActivityState, WaitReason } from '@mitzo/protocol'; +import type { LoopStatus } from './task-orchestrator.js'; +import type { TaskStore } from './task-store.js'; +import { createLogger } from './logger.js'; + +export type { SessionActivity, SessionActivityState, WaitReason } from '@mitzo/protocol'; + +const log = createLogger('session-overview'); + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Done → Idle after 5 minutes of inactivity. */ +const DONE_TIMEOUT_MS = 5 * 60 * 1000; + +/** Coalesce broadcasts within this window to avoid re-render storms. */ +const COALESCE_MS = 200; + +// ─── State priority for "highest wins" ──────────────────────────────────────── + +const STATE_PRIORITY: Record = { + idle: 0, + init: 1, + paused: 2, + done: 3, + working: 4, + waiting: 5, +}; + +// ─── Dependencies ───────────────────────────────────────────────────────────── + +export interface SessionOverviewDeps { + registry: SessionRegistry; + sseRegistry: SseRegistry; + getLoopStatus: () => LoopStatus; + taskStore: TaskStore; + /** Resolve session title. Typically eventStore.getSession(id)?.summary */ + getSessionTitle: (sessionId: string) => string | undefined; +} + +// ─── Emitter ────────────────────────────────────────────────────────────────── + +export class SessionOverviewEmitter { + private deps: SessionOverviewDeps; + private coalesceTimer: ReturnType | null = null; + /** Track last assistant event time per clientId for timeout derivation. */ + private lastEventTimes = new Map(); + + constructor(deps: SessionOverviewDeps) { + this.deps = deps; + } + + /** + * Record an event time for a session (call on turn start/end, attach, etc.). + * Used for Done → Idle timeout derivation. + */ + touch(clientId: string): void { + this.lastEventTimes.set(clientId, Date.now()); + } + + /** + * Clean up tracking for a removed session. + */ + forget(clientId: string): void { + this.lastEventTimes.delete(clientId); + } + + /** + * Schedule a coalesced broadcast. Multiple calls within COALESCE_MS + * collapse into a single broadcast. + */ + scheduleBroadcast(): void { + if (this.coalesceTimer) return; + this.coalesceTimer = setTimeout(() => { + this.coalesceTimer = null; + this.broadcast(); + }, COALESCE_MS); + } + + /** + * Compute the current snapshot and broadcast immediately. + * Used for hydration (new SSE client). + */ + getSnapshot(): SessionActivity[] { + return this.compute(); + } + + /** + * Broadcast the current snapshot to all SSE clients. + */ + private broadcast(): void { + const activities = this.compute(); + this.deps.sseRegistry.broadcast('session_activity', activities); + log.debug('broadcast session_activity', { count: activities.length }); + } + + /** + * Compute SessionActivity[] from all active sessions. + * State is derived purely from current data — no stored state. + */ + private compute(): SessionActivity[] { + const now = Date.now(); + const activeSessions = this.deps.registry.getActiveSessions(); + const loopStatus = this.deps.getLoopStatus(); + + return activeSessions + .filter((s) => s.sessionId) // Skip sessions without SDK IDs + .map((s) => this.deriveActivity(s, loopStatus, now)); + } + + private deriveActivity( + session: ActiveSessionInfo, + loopStatus: LoopStatus, + now: number, + ): SessionActivity { + const sessionId = session.sessionId ?? ''; + const lastEventAt = this.lastEventTimes.get(session.clientId) ?? now; + const elapsed = now - lastEventAt; + + // Collect all applicable states + const states: SessionActivityState[] = []; + let waitReason: WaitReason | undefined; + let progress: { done: number; total: number } | undefined; + let taskId: string | undefined; + + // 1. Permission check — "Waiting" for permission + const pendingCount = getPendingCountBySession(sessionId); + if (pendingCount > 0) { + states.push('waiting'); + waitReason = 'permission'; + } + + // 2. Task board — check for blocked/pending_review tasks linked to this session + if (session.taskContext) { + taskId = session.taskContext.goalId; + const taskWait = this.checkTaskWaitState(session.taskContext.goalId); + if (taskWait) { + states.push('waiting'); + if (!waitReason) waitReason = taskWait; + } + } + + // 3. ATB loop — check if this session is running the loop + if (loopStatus.state === 'running' && loopStatus.goalId) { + // Check if this session is the loop's pinned session + if (session.taskContext?.goalId === loopStatus.goalId) { + states.push('working'); + if (loopStatus.progress) { + progress = loopStatus.progress; + } + if (loopStatus.awaitingApproval) { + states.push('waiting'); + if (!waitReason) waitReason = 'review'; + } + } + } + + if (loopStatus.state === 'paused' && session.taskContext?.goalId === loopStatus.goalId) { + states.push('paused'); + } + + // 4. Transport/streaming state + if (session.attached) { + if (session.hasSnapshot) { + // Currently streaming a response + states.push('working'); + } else if (states.length === 0) { + // Attached but not streaming — either just finished or init + if (elapsed < DONE_TIMEOUT_MS) { + states.push('done'); + } else { + states.push('idle'); + } + } + } else { + // Detached + if (elapsed < DONE_TIMEOUT_MS) { + states.push('done'); + } else { + states.push('idle'); + } + } + + // Fallback: if no states collected, it's init + if (states.length === 0) { + states.push('init'); + } + + // Highest-priority state wins as primary + const primaryState = states.reduce((best, s) => + STATE_PRIORITY[s] > STATE_PRIORITY[best] ? s : best, + ); + + // Secondary flags = all other states (excluding primary, deduplicated) + const flags = [...new Set(states.filter((s) => s !== primaryState))]; + + // Derive title + const title = this.deps.getSessionTitle(sessionId) || sessionId.slice(-8); + + // Derive repo from cwd + const repo = session.cwd ? extractRepoName(session.cwd) : undefined; + + return { + sessionId, + clientId: session.clientId, + title, + repo, + state: primaryState, + flags, + waitReason: primaryState === 'waiting' ? waitReason : undefined, + progress, + lastEventAt, + taskId, + }; + } + + /** + * Check if any child task of a goal is blocked or pending_review. + */ + private checkTaskWaitState(goalId: string): WaitReason | null { + const tree = this.deps.taskStore.getTree(); + const goal = tree.find((t) => t.id === goalId); + if (!goal) return null; + + // Check the goal and direct children only (single-level task decomposition) + for (const task of tree) { + if (task.id === goalId || task.parentId === goalId) { + if (task.status === 'pending_review') return 'review'; + if (task.status === 'blocked') return 'blocked'; + } + } + return null; + } + + destroy(): void { + if (this.coalesceTimer) { + clearTimeout(this.coalesceTimer); + this.coalesceTimer = null; + } + this.lastEventTimes.clear(); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Extract a short repo name from a cwd path. + * e.g. "/Users/foo/tools/mitzo" → "mitzo" + * "/Users/foo/redhat/mgmt/.claude/worktrees/abc123" → "mgmt" + */ +function extractRepoName(cwd: string): string { + // Strip worktree suffix: .claude/worktrees/ or .cursor/worktrees/ + const worktreeMatch = cwd.match(/^(.+)\/\.(claude|cursor)\/worktrees\//); + const base = worktreeMatch ? worktreeMatch[1] : cwd; + const parts = base.split('/').filter(Boolean); + return parts[parts.length - 1] || 'unknown'; +} diff --git a/server/task-orchestrator.ts b/server/task-orchestrator.ts index a6c36554..05c5fc8c 100644 --- a/server/task-orchestrator.ts +++ b/server/task-orchestrator.ts @@ -1,4 +1,5 @@ import type { TaskStore, Task, GateConfig } from './task-store.js'; +import type { WorkloadStore } from './workload-store.js'; import { sendToChat } from './chat.js'; import { createLogger } from './logger.js'; @@ -21,6 +22,7 @@ export interface StartOptions { export interface OrchestratorDeps { store: TaskStore; + workloadStore?: WorkloadStore; /** Resolve session's clientId for the reuse session */ getClientId: () => string | null; /** Set task context on the session */ @@ -274,6 +276,13 @@ export class TaskOrchestrator { goalId: this.goalId, status: goalStatus, }); + + // Lifecycle sync: complete linked TodoItems when goal completes + if (goalStatus === 'done' && this.deps.workloadStore) { + this.deps.workloadStore.completeByGoal(this.goalId); + log.info('completed linked workload items', { goalId: this.goalId }); + } + this.stop(); return; } diff --git a/server/workload-store.ts b/server/workload-store.ts new file mode 100644 index 00000000..54b0315e --- /dev/null +++ b/server/workload-store.ts @@ -0,0 +1,512 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; +import { createLogger } from './logger.js'; + +const log = createLogger('workload-store'); + +// --- Types --- + +export type TodoStatus = 'active' | 'acknowledged' | 'snoozed' | 'completed'; + +export interface ContextHints { + repos: string[]; + paths: string[]; + issues: string[]; + docIds: string[]; + people: string[]; + jiraKeys: string[]; + keywords: string[]; + taskHint: string; +} + +export interface TodoSource { + id: string; + itemId: string; + sourceType: string; + sourceId: string; + url: string; + title: string; + author: string; + timestamp: number; + snippet: string; +} + +export interface TodoItem { + id: string; + title: string; + snippet: string | null; + status: TodoStatus; + profile: string; + urgency: number; + starred: boolean; + snoozedUntil: string | null; + contextHints: ContextHints; + clusterId: string | null; + goalId: string | null; + sources: TodoSource[]; + createdAt: number; + updatedAt: number; +} + +export interface WorkSignal { + sourceType: string; + sourceId: string; + url: string; + title: string; + snippet: string; + author: string; + timestamp: string; // ISO 8601 + contextHints?: Partial; + urgencyHint?: number; + profile?: string; +} + +export interface TodoItemUpdateInput { + title?: string; + status?: TodoStatus; + starred?: boolean; + snoozedUntil?: string | null; + urgency?: number; + contextHints?: Partial; +} + +// --- DB row types --- + +interface TodoItemRow { + id: string; + title: string; + snippet: string | null; + status: string; + profile: string; + urgency: number; + starred: number; + snoozed_until: string | null; + context_hints: string | null; + cluster_id: string | null; + goal_id: string | null; + created_at: number; + updated_at: number; +} + +interface TodoSourceRow { + id: string; + item_id: string; + source_type: string; + source_id: string; + url: string; + title: string; + author: string; + timestamp: number; + snippet: string | null; +} + +// --- Schema --- + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS todo_items ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + snippet TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','acknowledged','snoozed','completed')), + profile TEXT NOT NULL DEFAULT 'default', + urgency REAL NOT NULL DEFAULT 0.0, + starred INTEGER NOT NULL DEFAULT 0, + snoozed_until TEXT, + context_hints TEXT, + cluster_id TEXT, + goal_id TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS todo_sources ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT NOT NULL, + timestamp REAL NOT NULL, + snippet TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_todo_profile ON todo_items(profile); + CREATE INDEX IF NOT EXISTS idx_todo_status ON todo_items(status); + CREATE INDEX IF NOT EXISTS idx_todo_sources_item ON todo_sources(item_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_todo_sources_dedup ON todo_sources(source_type, source_id); +`; + +// --- Helpers --- + +/** Sentinel for missing context hints. Frozen to prevent accidental mutation via shallow copies. */ +const EMPTY_HINTS: ContextHints = Object.freeze({ + repos: [], + paths: [], + issues: [], + docIds: [], + people: [], + jiraKeys: [], + keywords: [], + taskHint: '', +}) as ContextHints; + +function parseContextHints(raw: string | null): ContextHints { + if (!raw) return { ...EMPTY_HINTS }; + try { + const parsed = JSON.parse(raw); + return { ...EMPTY_HINTS, ...parsed }; + } catch { + return { ...EMPTY_HINTS }; + } +} + +function mergeContextHints(existing: ContextHints, incoming: Partial): ContextHints { + const dedup = (a: string[], b: string[] | undefined) => [...new Set([...a, ...(b ?? [])])]; + return { + repos: dedup(existing.repos, incoming.repos), + paths: dedup(existing.paths, incoming.paths), + issues: dedup(existing.issues, incoming.issues), + docIds: dedup(existing.docIds, incoming.docIds), + people: dedup(existing.people, incoming.people), + jiraKeys: dedup(existing.jiraKeys, incoming.jiraKeys), + keywords: dedup(existing.keywords, incoming.keywords), + taskHint: incoming.taskHint || existing.taskHint, + }; +} + +function rowToItem(row: TodoItemRow, sources: TodoSource[]): TodoItem { + return { + id: row.id, + title: row.title, + snippet: row.snippet, + status: row.status as TodoStatus, + profile: row.profile, + urgency: row.urgency, + starred: row.starred === 1, + snoozedUntil: row.snoozed_until, + contextHints: parseContextHints(row.context_hints), + clusterId: row.cluster_id, + goalId: row.goal_id, + sources, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToSource(row: TodoSourceRow): TodoSource { + return { + id: row.id, + itemId: row.item_id, + sourceType: row.source_type, + sourceId: row.source_id, + url: row.url, + title: row.title, + author: row.author, + timestamp: row.timestamp, + snippet: row.snippet ?? '', + }; +} + +// --- Scoring --- + +function computeUrgency(signal: WorkSignal): number { + const base = signal.urgencyHint ?? 0.3; + const ageMs = Date.now() - new Date(signal.timestamp).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const ageBoost = ageDays > 7 ? 0.1 : 0; + return Math.min(1.0, base + ageBoost); +} + +// --- Store --- + +export class WorkloadStore { + private db: Database.Database | null; + + constructor(db: Database.Database) { + this.db = db; + db.exec(SCHEMA); + log.info('WorkloadStore initialized (shared DB)'); + } + + close(): void { + // Don't close — DB is shared with TaskStore + this.db = null; + } + + private getDb(): Database.Database { + if (!this.db) throw new Error('WorkloadStore is closed'); + return this.db; + } + + /** + * Ingest a WorkSignal. Deduplicates by (sourceType, sourceId). + * - New source: creates a new item + source. + * - Existing source: updates timestamp, returns existing item. + */ + ingest(signal: WorkSignal): { item: TodoItem; created: boolean } { + const db = this.getDb(); + const now = Date.now(); + const signalTs = new Date(signal.timestamp).getTime(); + + // Validate timestamp format + if (isNaN(signalTs)) { + throw new Error(`Invalid timestamp format: ${signal.timestamp}`); + } + + // Check if this exact source already exists + const existingSource = db + .prepare('SELECT * FROM todo_sources WHERE source_type = ? AND source_id = ?') + .get(signal.sourceType, signal.sourceId) as TodoSourceRow | undefined; + + if (existingSource) { + // Source exists — update timestamp, return existing item + db.prepare('UPDATE todo_sources SET timestamp = ? WHERE id = ?').run( + signalTs, + existingSource.id, + ); + db.prepare('UPDATE todo_items SET updated_at = ? WHERE id = ?').run( + now, + existingSource.item_id, + ); + return { item: this.get(existingSource.item_id)!, created: false }; + } + + // New source — create a new item + const profile = signal.profile ?? 'default'; + const itemId = randomUUID(); + const hints = signal.contextHints + ? mergeContextHints({ ...EMPTY_HINTS }, signal.contextHints) + : { ...EMPTY_HINTS }; + + db.prepare( + `INSERT INTO todo_items (id, title, snippet, status, profile, urgency, starred, context_hints, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?, 0, ?, ?, ?)`, + ).run( + itemId, + signal.title, + signal.snippet || null, + profile, + computeUrgency(signal), + JSON.stringify(hints), + now, + now, + ); + + // Create source + const sourceId = randomUUID(); + db.prepare( + `INSERT INTO todo_sources (id, item_id, source_type, source_id, url, title, author, timestamp, snippet) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + sourceId, + itemId, + signal.sourceType, + signal.sourceId, + signal.url, + signal.title, + signal.author, + signalTs, + signal.snippet || null, + ); + + log.info('Ingested new work signal', { + itemId, + sourceType: signal.sourceType, + sourceId: signal.sourceId, + profile, + }); + + return { item: this.get(itemId)!, created: true }; + } + + /** + * Ingest multiple signals in a single transaction. + */ + ingestBatch(signals: WorkSignal[]): { items: TodoItem[]; created: number } { + const db = this.getDb(); + const results: TodoItem[] = []; + let created = 0; + + const tx = db.transaction(() => { + for (const signal of signals) { + const result = this.ingest(signal); + results.push(result.item); + if (result.created) created++; + } + }); + + tx(); + return { items: results, created }; + } + + get(id: string): TodoItem | null { + const row = this.getDb().prepare('SELECT * FROM todo_items WHERE id = ?').get(id) as + | TodoItemRow + | undefined; + if (!row) return null; + + const sourceRows = this.getDb() + .prepare('SELECT * FROM todo_sources WHERE item_id = ? ORDER BY timestamp DESC') + .all(id) as TodoSourceRow[]; + + return rowToItem(row, sourceRows.map(rowToSource)); + } + + list(options?: { profile?: string; status?: TodoStatus; starred?: boolean }): TodoItem[] { + const db = this.getDb(); + const conditions: string[] = []; + const values: unknown[] = []; + + if (options?.profile) { + conditions.push('profile = ?'); + values.push(options.profile); + } + if (options?.status) { + conditions.push('status = ?'); + values.push(options.status); + } + if (options?.starred !== undefined) { + conditions.push('starred = ?'); + values.push(options.starred ? 1 : 0); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const rows = db + .prepare( + `SELECT * FROM todo_items ${where} ORDER BY starred DESC, urgency DESC, created_at ASC`, + ) + .all(...values) as TodoItemRow[]; + + // Batch-load sources for all items + const itemIds = rows.map((r) => r.id); + const sourceMap = this.loadSourcesForItems(itemIds); + + return rows.map((row) => rowToItem(row, sourceMap.get(row.id) ?? [])); + } + + update(id: string, fields: TodoItemUpdateInput): TodoItem | null { + const existing = this.get(id); + if (!existing) return null; + + const sets: string[] = []; + const values: unknown[] = []; + + if (fields.title !== undefined) { + sets.push('title = ?'); + values.push(fields.title); + } + if (fields.status !== undefined) { + sets.push('status = ?'); + values.push(fields.status); + } + if (fields.starred !== undefined) { + sets.push('starred = ?'); + values.push(fields.starred ? 1 : 0); + } + if (fields.snoozedUntil !== undefined) { + sets.push('snoozed_until = ?'); + values.push(fields.snoozedUntil); + if (fields.snoozedUntil && fields.status === undefined) { + sets.push('status = ?'); + values.push('snoozed'); + } + } + if (fields.urgency !== undefined) { + sets.push('urgency = ?'); + values.push(fields.urgency); + } + if (fields.contextHints !== undefined) { + const merged = mergeContextHints(existing.contextHints, fields.contextHints); + sets.push('context_hints = ?'); + values.push(JSON.stringify(merged)); + } + + if (sets.length === 0) return existing; + + sets.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.getDb() + .prepare(`UPDATE todo_items SET ${sets.join(', ')} WHERE id = ?`) + .run(...values); + + return this.get(id); + } + + delete(id: string): boolean { + const result = this.getDb().prepare('DELETE FROM todo_items WHERE id = ?').run(id); + return result.changes > 0; + } + + /** + * Link a todo item to a task board goal (root task). + */ + setGoalId(itemId: string, goalId: string): TodoItem | null { + const db = this.getDb(); + db.prepare('UPDATE todo_items SET goal_id = ?, status = ?, updated_at = ? WHERE id = ?').run( + goalId, + 'acknowledged', + Date.now(), + itemId, + ); + return this.get(itemId); + } + + /** + * Complete items linked to a completed goal. + */ + completeByGoal(goalId: string): void { + this.getDb() + .prepare( + "UPDATE todo_items SET status = 'completed', updated_at = ? WHERE goal_id = ? AND status != 'completed'", + ) + .run(Date.now(), goalId); + } + + /** + * Unsnooze items whose snooze period has expired. + */ + unsnoozeDue(): number { + const today = new Date().toISOString().slice(0, 10); + const result = this.getDb() + .prepare( + "UPDATE todo_items SET status = 'active', snoozed_until = NULL, updated_at = ? WHERE status = 'snoozed' AND snoozed_until <= ?", + ) + .run(Date.now(), today); + return result.changes; + } + + /** + * Get profiles with item counts. + */ + profiles(): { profile: string; count: number }[] { + return this.getDb() + .prepare( + "SELECT profile, COUNT(*) as count FROM todo_items WHERE status != 'completed' GROUP BY profile ORDER BY count DESC", + ) + .all() as { profile: string; count: number }[]; + } + + private loadSourcesForItems(itemIds: string[]): Map { + if (itemIds.length === 0) return new Map(); + + const db = this.getDb(); + const placeholders = itemIds.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT * FROM todo_sources WHERE item_id IN (${placeholders}) ORDER BY timestamp DESC`, + ) + .all(...itemIds) as TodoSourceRow[]; + + const map = new Map(); + for (const row of rows) { + const sources = map.get(row.item_id) ?? []; + sources.push(rowToSource(row)); + map.set(row.item_id, sources); + } + return map; + } +} diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index 73ba00a4..61202c89 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -17,6 +17,7 @@ import { UnwatchMessage, SwitchSessionMessage, SessionSuspendMessage, + SessionCloseMessage, V2SendMessage, V2StopMessage, V2InterruptMessage, @@ -29,6 +30,7 @@ type WatchMsg = z.infer; type UnwatchMsg = z.infer; type SwitchSessionMsg = z.infer; type SessionSuspendMsg = z.infer; +type SessionCloseMsg = z.infer; type SendMsg = z.infer; type StopMsg = z.infer; type InterruptMsg = z.infer; @@ -43,6 +45,7 @@ import { sendToChat, interruptChat, stopChat, + closeSessionByUser, isActive, reattachChat, rekeyChat, @@ -127,6 +130,10 @@ export function handleReconnect( for (const entry of msg.sessions) { ctx.connRegistry.watch(connectionId, entry.sessionId); + // Set cursor to client's lastSeq BEFORE replay, so periodic sync + // sees a reasonable cursor during replay instead of 0. + ctx.connRegistry.resetCursor(connectionId, entry.sessionId, entry.lastSeq); + const events = ctx.eventStore.getEventsAfter(entry.sessionId, entry.lastSeq); for (const evt of events) { ctx.connRegistry.get(connectionId)?.transport.send({ @@ -135,6 +142,11 @@ export function handleReconnect( } as Record); } + // Reset cursor to last replayed seq — prevents duplicate delivery from + // periodic sync. If no events replayed, cursor stays at client's lastSeq. + const newCursor = events.length > 0 ? events[events.length - 1].seq : entry.lastSeq; + ctx.connRegistry.resetCursor(connectionId, entry.sessionId, newCursor); + // Cross-reference with the durable EventStore: markSessionInactive() is // called in the query loop's finally block, so isActive=false in the // store is ground truth that the loop has ended. @@ -436,6 +448,7 @@ export function handleSendV2( images: msg.images, contextBlocks: msg.contextBlocks, clientMsgId: msg.clientMsgId, + telosTaskId: msg.telosTaskId, }); applySkillPolicy(sessionClientId); } else { @@ -455,6 +468,7 @@ export function handleSendV2( contextBlocks: msg.contextBlocks, clientMsgId: msg.clientMsgId, onSessionResolved, + telosTaskId: msg.telosTaskId, }); applySkillPolicy(sessionClientId); } @@ -647,6 +661,61 @@ export function handleSessionSuspend( ); } +export function handleSessionClose( + connectionId: string, + msg: SessionCloseMsg, + ctx: V2HandlerContext, +): void { + withSpan( + 'ws.session_close', + { 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId }, + () => { + const found = ctx.sessionRegistry.findBySessionId(msg.sessionId); + const conn = ctx.connRegistry.get(connectionId); + + if (!found) { + log.warn('close: session not found', { connectionId, sessionId: msg.sessionId }); + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: false, + reason: 'Session not found', + }); + return; + } + + const ownerConnection = getOwnerConnection(found.clientId); + if (ownerConnection !== connectionId) { + log.warn('close: not owner', { + connectionId, + sessionId: msg.sessionId, + owner: ownerConnection, + }); + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: false, + reason: 'Not session owner', + }); + return; + } + + closeSessionByUser(found.clientId); + log.info('session close initiated by user', { + connectionId, + sessionId: msg.sessionId, + clientId: found.clientId, + }); + + conn?.transport.send({ + type: 'session_close_ack', + sessionId: msg.sessionId, + accepted: true, + }); + }, + ); +} + // ─── Dispatcher ────────────────────────────────────────────────────────────── /** @@ -697,6 +766,9 @@ export async function dispatchV2Message( case 'session_suspend': handleSessionSuspend(connectionId, msg, ctx); break; + case 'session_close': + handleSessionClose(connectionId, msg, ctx); + break; case 'send': handleSendV2(connectionId, transport, msg, ctx); break;