From 959ba94eca2d462949cc5eb5a31d0742c443774c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 18:04:11 +0100 Subject: [PATCH] macOS: add settings previews --- .../macos/Sources/Clawdis/AboutSettings.swift | 10 ++ apps/macos/Sources/Clawdis/AppState.swift | 136 ++++++++++++------ .../Sources/Clawdis/ConfigSettings.swift | 11 ++ apps/macos/Sources/Clawdis/DebugActions.swift | 20 ++- .../macos/Sources/Clawdis/DebugSettings.swift | 23 ++- .../Sources/Clawdis/GatewayChannel.swift | 2 +- .../Sources/Clawdis/GeneralSettings.swift | 11 ++ .../Sources/Clawdis/InstancesSettings.swift | 15 +- .../Sources/Clawdis/InstancesStore.swift | 36 +++++ .../Sources/Clawdis/PermissionsSettings.swift | 20 +++ apps/macos/Sources/Clawdis/SessionData.swift | 43 ++++++ .../Sources/Clawdis/SessionsSettings.swift | 23 ++- .../Sources/Clawdis/SettingsRootView.swift | 26 +++- .../macos/Sources/Clawdis/ToolsSettings.swift | 9 ++ apps/macos/Sources/Clawdis/Utilities.swift | 6 + .../Sources/Clawdis/VoiceWakeSettings.swift | 27 +++- 16 files changed, 360 insertions(+), 58 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AboutSettings.swift b/apps/macos/Sources/Clawdis/AboutSettings.swift index 23ba40bc0..0d74b539f 100644 --- a/apps/macos/Sources/Clawdis/AboutSettings.swift +++ b/apps/macos/Sources/Clawdis/AboutSettings.swift @@ -177,3 +177,13 @@ private struct AboutMetaRow: View { } } } + +#if DEBUG +struct AboutSettings_Previews: PreviewProvider { + private static let updater = DisabledUpdaterController() + static var previews: some View { + AboutSettings(updater: updater) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 2211a7d07..2a0ff705d 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -5,99 +5,116 @@ import SwiftUI @MainActor final class AppState: ObservableObject { + private let isPreview: Bool + + private func ifNotPreview(_ action: () -> Void) { + guard !self.isPreview else { return } + action() + } + enum ConnectionMode: String { case local case remote } @Published var isPaused: Bool { - didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } } @Published var launchAtLogin: Bool { - didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } + didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } } } @Published var onboardingSeen: Bool { - didSet { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") } } } @Published var debugPaneEnabled: Bool { - didSet { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } } } @Published var swabbleEnabled: Bool { didSet { - UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + self.ifNotPreview { + UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } } } @Published var swabbleTriggerWords: [String] { didSet { // Preserve the raw editing state; sanitization happens when we actually use the triggers. - UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + self.ifNotPreview { + UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } } } } @Published var voiceWakeTriggerChime: VoiceWakeChime { - didSet { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } + didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } } } @Published var voiceWakeSendChime: VoiceWakeChime { - didSet { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } + didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } } } @Published var iconAnimationsEnabled: Bool { - didSet { UserDefaults.standard.set(self.iconAnimationsEnabled, forKey: iconAnimationsEnabledKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconAnimationsEnabled, forKey: iconAnimationsEnabledKey) } } } @Published var showDockIcon: Bool { didSet { - UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) - AppActivationPolicy.apply(showDockIcon: self.showDockIcon) + self.ifNotPreview { + UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) + AppActivationPolicy.apply(showDockIcon: self.showDockIcon) + } } } @Published var voiceWakeMicID: String { didSet { - UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + self.ifNotPreview { + UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } } } } @Published var voiceWakeLocaleID: String { didSet { - UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + self.ifNotPreview { + UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } } } } @Published var voiceWakeAdditionalLocaleIDs: [String] { - didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } } } @Published var voiceWakeForwardEnabled: Bool { - didSet { UserDefaults.standard.set(self.voiceWakeForwardEnabled, forKey: voiceWakeForwardEnabledKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeForwardEnabled, forKey: voiceWakeForwardEnabledKey) } } } @Published var voiceWakeForwardCommand: String { - didSet { UserDefaults.standard.set(self.voiceWakeForwardCommand, forKey: voiceWakeForwardCommandKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeForwardCommand, forKey: voiceWakeForwardCommandKey) } } } @Published var voicePushToTalkEnabled: Bool { - didSet { UserDefaults.standard.set(self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) } } } @Published var iconOverride: IconOverrideSelection { - didSet { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } } @Published var isWorking: Bool = false @@ -106,38 +123,41 @@ final class AppState: ObservableObject { @Published var sendCelebrationTick: Int = 0 @Published var heartbeatsEnabled: Bool { didSet { - UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) - Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } + self.ifNotPreview { + UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) + Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } + } } } @Published var connectionMode: ConnectionMode { - didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } } } @Published var webChatEnabled: Bool { - didSet { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } } } @Published var webChatPort: Int { - didSet { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } } } @Published var remoteTarget: String { - didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } } } @Published var remoteIdentity: String { - didSet { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @Published var remoteProjectRoot: String { - didSet { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } } private var earBoostTask: Task? - init() { + init(preview: Bool = false) { + self.isPreview = preview self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.launchAtLogin = false self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") @@ -199,16 +219,20 @@ final class AppState: ObservableObject { let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) self.webChatPort = storedPort > 0 ? storedPort : 18788 - Task.detached(priority: .utility) { [weak self] in - let current = await LaunchAgentManager.status() - await MainActor.run { [weak self] in self?.launchAtLogin = current } + if !self.isPreview { + Task.detached(priority: .utility) { [weak self] in + let current = await LaunchAgentManager.status() + await MainActor.run { [weak self] in self?.launchAtLogin = current } + } } if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { self.swabbleEnabled = false } - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + if !self.isPreview { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } } func triggerVoiceEars(ttl: TimeInterval? = 5) { @@ -243,14 +267,15 @@ final class AppState: ObservableObject { return } + self.swabbleEnabled = enabled + guard !self.isPreview else { return } + if !enabled { - self.swabbleEnabled = false Task { await VoiceWakeRuntime.shared.refresh(state: self) } return } if PermissionManager.voiceWakePermissionsGranted() { - self.swabbleEnabled = true Task { await VoiceWakeRuntime.shared.refresh(state: self) } return } @@ -280,6 +305,37 @@ final class AppState: ObservableObject { } } +extension AppState { + static var preview: AppState { + let state = AppState(preview: true) + state.isPaused = false + state.launchAtLogin = true + state.onboardingSeen = true + state.debugPaneEnabled = true + state.swabbleEnabled = true + state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"] + state.voiceWakeTriggerChime = .system(name: "Glass") + state.voiceWakeSendChime = .system(name: "Ping") + state.iconAnimationsEnabled = true + state.showDockIcon = true + state.voiceWakeMicID = "BuiltInMic" + state.voiceWakeLocaleID = Locale.current.identifier + state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] + state.voiceWakeForwardEnabled = true + state.voiceWakeForwardCommand = defaultVoiceWakeForwardCommand + state.voicePushToTalkEnabled = false + state.iconOverride = .system + state.heartbeatsEnabled = true + state.connectionMode = .local + state.webChatEnabled = true + state.webChatPort = 18788 + state.remoteTarget = "user@example.com" + state.remoteIdentity = "~/.ssh/id_ed25519" + state.remoteProjectRoot = "~/Projects/clawdis" + return state + } +} + @MainActor enum AppStateStore { static let shared = AppState() diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index ba4c1a9d1..1b3d2285d 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -2,6 +2,7 @@ import SwiftUI @MainActor struct ConfigSettings: View { + private let isPreview = ProcessInfo.processInfo.isPreview @State private var configModel: String = "" @State private var customModel: String = "" @State private var configSaving = false @@ -131,6 +132,7 @@ struct ConfigSettings: View { } .task { guard !self.hasLoaded else { return } + guard !self.isPreview else { return } self.hasLoaded = true self.loadConfig() await self.loadModels() @@ -247,3 +249,12 @@ struct ConfigSettings: View { return "Context window: \(human) tokens" } } + +#if DEBUG +struct ConfigSettings_Previews: PreviewProvider { + static var previews: some View { + ConfigSettings() + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index e0fd02e90..fdb0b29e1 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -36,7 +36,7 @@ enum DebugActions { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } - static func sendDebugVoice() async -> Result { + static func sendDebugVoice() async -> Result { let message = """ This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \ if you received that. @@ -51,7 +51,7 @@ enum DebugActions { return .success("Forwarded. Await reply.") case let .failure(error): let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - return .failure("Forward failed: \(detail)") + return .failure(.message("Forward failed: \(detail)")) } } @@ -75,11 +75,11 @@ enum DebugActions { let detail = (reason?.isEmpty == false) ? reason! : "No error returned. Check /tmp/clawdis.log or rpc output." - return .failure("Local send failed: \(detail)") + return .failure(.message("Local send failed: \(detail)")) } } catch { let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - return .failure("Local send failed: \(detail)") + return .failure(.message("Local send failed: \(detail)")) } } @@ -91,7 +91,7 @@ enum DebugActions { } } - private static func pinoLogPath() -> String { + static func pinoLogPath() -> String { let df = DateFormatter() df.calendar = Calendar(identifier: .iso8601) df.locale = Locale(identifier: "en_US_POSIX") @@ -102,3 +102,13 @@ enum DebugActions { return "/tmp/clawdis.log" } } + +enum DebugActionError: LocalizedError { + case message(String) + + var errorDescription: String? { + switch self { + case let .message(text): text + } + } +} diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 5634af4e3..ac56185f8 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -3,6 +3,7 @@ import SwiftUI import UniformTypeIdentifiers struct DebugSettings: View { + private let isPreview = ProcessInfo.processInfo.isPreview @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue @@ -44,9 +45,9 @@ struct DebugSettings: View { } LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") } LabeledContent("Log file") { - Button("Open pino log") { self.openLog() } - .help(self.pinoLogPath) - Text(self.pinoLogPath) + Button("Open pino log") { DebugActions.openLog() } + .help(DebugActions.pinoLogPath()) + Text(DebugActions.pinoLogPath()) .font(.caption2.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) @@ -205,6 +206,7 @@ struct DebugSettings: View { .padding(.vertical, 8) } .task { + guard !self.isPreview else { return } await self.reloadModels() self.loadSessionStorePath() } @@ -256,9 +258,9 @@ struct DebugSettings: View { case let .success(message): self.debugSendStatus = message self.debugSendError = nil - case let .failure(message): - self.debugSendStatus = message - self.debugSendError = nil + case let .failure(error): + self.debugSendStatus = nil + self.debugSendError = error.localizedDescription } } } @@ -348,3 +350,12 @@ struct DebugSettings: View { .appendingPathComponent("clawdis.json") } } + +#if DEBUG +struct DebugSettings_Previews: PreviewProvider { + static var previews: some View { + DebugSettings() + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 7fe7af3e6..373f0b38e 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -238,7 +238,7 @@ private actor GatewayChannelActor { if let urlError = error as? URLError { let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription return NSError( - domain: urlError.errorDomain, + domain: URLError.errorDomain, code: urlError.errorCode, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) } diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index a0e0e8dbd..fbe2074e8 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -13,6 +13,7 @@ struct GeneralSettings: View { @State private var relayInstalling = false @State private var remoteStatus: RemoteStatus = .idle @State private var showRemoteAdvanced = false + private let isPreview = ProcessInfo.processInfo.isPreview var body: some View { ScrollView(.vertical) { @@ -65,6 +66,7 @@ struct GeneralSettings: View { .padding(.bottom, 16) } .onAppear { + guard !self.isPreview else { return } self.refreshCLIStatus() self.refreshRelayStatus() } @@ -527,3 +529,12 @@ private func healthAgeString(_ ms: Double?) -> String { guard let ms else { return "unknown" } return msToAge(ms) } + +#if DEBUG +struct GeneralSettings_Previews: PreviewProvider { + static var previews: some View { + GeneralSettings(state: .preview) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index fef5cd900..7b5570154 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -1,7 +1,11 @@ import SwiftUI struct InstancesSettings: View { - @StateObject private var store = InstancesStore.shared + @ObservedObject var store: InstancesStore + + init(store: InstancesStore = .shared) { + self.store = store + } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -87,3 +91,12 @@ struct InstancesSettings: View { .font(.footnote) } } + +#if DEBUG +struct InstancesSettings_Previews: PreviewProvider { + static var previews: some View { + InstancesSettings(store: .preview()) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index 814997e84..9abb7a156 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -28,6 +28,7 @@ struct InstanceInfo: Identifiable, Codable { @MainActor final class InstancesStore: ObservableObject { static let shared = InstancesStore() + let isPreview: Bool @Published var instances: [InstanceInfo] = [] @Published var lastError: String? @@ -39,7 +40,12 @@ final class InstancesStore: ObservableObject { private let interval: TimeInterval = 30 private var observers: [NSObjectProtocol] = [] + init(isPreview: Bool = false) { + self.isPreview = isPreview + } + func start() { + guard !self.isPreview else { return } guard self.task == nil else { return } self.observeGatewayEvents() self.task = Task.detached { [weak self] in @@ -295,3 +301,33 @@ final class InstancesStore: ObservableObject { } } } + +extension InstancesStore { + static func preview(instances: [InstanceInfo] = [ + InstanceInfo( + id: "local", + host: "steipete-mac", + ip: "10.0.0.12", + version: "1.2.3", + lastInputSeconds: 12, + mode: "local", + reason: "preview", + text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", + ts: Date().timeIntervalSince1970 * 1000), + InstanceInfo( + id: "relay", + host: "relay", + ip: "100.64.0.2", + version: "1.2.3", + lastInputSeconds: 45, + mode: "remote", + reason: "preview", + text: "Relay node · tunnel ok", + ts: Date().timeIntervalSince1970 * 1000 - 45_000), + ]) -> InstancesStore { + let store = InstancesStore(isPreview: true) + store.instances = instances + store.statusMessage = "Preview data" + return store + } +} diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift index e8719ac72..599b9f317 100644 --- a/apps/macos/Sources/Clawdis/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -118,3 +118,23 @@ struct PermissionRow: View { } } } + +#if DEBUG +struct PermissionsSettings_Previews: PreviewProvider { + static var previews: some View { + PermissionsSettings( + status: [ + .appleScript: true, + .notifications: true, + .accessibility: false, + .screenRecording: false, + .microphone: true, + .speechRecognition: false, + ], + refresh: {}, + showOnboarding: {} + ) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index b2baa3a40..1546b0ff4 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -95,6 +95,49 @@ struct SessionDefaults { let contextTokens: Int } +extension SessionRow { + static var previewRows: [SessionRow] { + [ + SessionRow( + id: "direct-1", + key: "user@example.com", + kind: .direct, + updatedAt: Date().addingTimeInterval(-90), + sessionId: "sess-direct-1234", + thinkingLevel: "low", + verboseLevel: "info", + systemSent: false, + abortedLastRun: false, + tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000), + model: "claude-3.5-sonnet"), + SessionRow( + id: "group-1", + key: "group:engineering", + kind: .group, + updatedAt: Date().addingTimeInterval(-3600), + sessionId: "sess-group-4321", + thinkingLevel: "medium", + verboseLevel: nil, + systemSent: true, + abortedLastRun: true, + tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000), + model: "claude-opus-4-5"), + SessionRow( + id: "global", + key: "global", + kind: .global, + updatedAt: Date().addingTimeInterval(-86_400), + sessionId: nil, + thinkingLevel: nil, + verboseLevel: nil, + systemSent: false, + abortedLastRun: false, + tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000), + model: "gpt-4.1-mini"), + ] + } +} + struct ModelChoice: Identifiable, Hashable { let id: String let name: String diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift index 5e816e8c4..e8e35c8b8 100644 --- a/apps/macos/Sources/Clawdis/SessionsSettings.swift +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -3,13 +3,23 @@ import SwiftUI @MainActor struct SessionsSettings: View { - @State private var rows: [SessionRow] = [] + private let isPreview: Bool + @State private var rows: [SessionRow] @State private var storePath: String = SessionLoader.defaultStorePath @State private var lastLoaded: Date? @State private var errorMessage: String? @State private var loading = false @State private var hasLoaded = false + init(rows: [SessionRow]? = nil, isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self._rows = State(initialValue: rows ?? []) + self.isPreview = isPreview + if isPreview { + self._lastLoaded = State(initialValue: Date()) + self._hasLoaded = State(initialValue: true) + } + } + var body: some View { VStack(alignment: .leading, spacing: 14) { self.header @@ -22,6 +32,7 @@ struct SessionsSettings: View { .padding(.horizontal, 12) .task { guard !self.hasLoaded else { return } + guard !self.isPreview else { return } self.hasLoaded = true await self.refresh() } @@ -149,6 +160,7 @@ struct SessionsSettings: View { private func refresh() async { guard !self.loading else { return } + guard !self.isPreview else { return } self.loading = true self.errorMessage = nil @@ -209,3 +221,12 @@ private struct Badge: View { .clipShape(Capsule()) } } + +#if DEBUG +struct SessionsSettings_Previews: PreviewProvider { + static var previews: some View { + SessionsSettings(rows: SessionRow.previewRows, isPreview: true) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 6a0f0fed7..72d188efa 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -6,6 +6,13 @@ struct SettingsRootView: View { @State private var monitoringPermissions = false @State private var selectedTab: SettingsTab = .general let updater: UpdaterProviding? + private let isPreview = ProcessInfo.processInfo.isPreview + + init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) { + self.state = state + self.updater = updater + self._selectedTab = State(initialValue: initialTab ?? .general) + } var body: some View { TabView(selection: self.$selectedTab) { @@ -76,7 +83,10 @@ struct SettingsRootView: View { self.updatePermissionMonitoring(for: newValue) } .onDisappear { self.stopPermissionMonitoring() } - .task { await self.refreshPerms() } + .task { + guard !self.isPreview else { return } + await self.refreshPerms() + } } private func validTab(for requested: SettingsTab) -> SettingsTab { @@ -86,10 +96,12 @@ struct SettingsRootView: View { @MainActor private func refreshPerms() async { + guard !self.isPreview else { return } await self.permissionMonitor.refreshNow() } private func updatePermissionMonitoring(for tab: SettingsTab) { + guard !self.isPreview else { return } let shouldMonitor = tab == .permissions if shouldMonitor, !self.monitoringPermissions { self.monitoringPermissions = true @@ -143,3 +155,15 @@ enum SettingsTabRouter { extension Notification.Name { static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab") } + +#if DEBUG +struct SettingsRootView_Previews: PreviewProvider { + static var previews: some View { + ForEach(SettingsTab.allCases, id: \.self) { tab in + SettingsRootView(state: .preview, updater: DisabledUpdaterController(), initialTab: tab) + .previewDisplayName(tab.title) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/ToolsSettings.swift b/apps/macos/Sources/Clawdis/ToolsSettings.swift index b031dc0fe..2eac74653 100644 --- a/apps/macos/Sources/Clawdis/ToolsSettings.swift +++ b/apps/macos/Sources/Clawdis/ToolsSettings.swift @@ -386,3 +386,12 @@ private enum ToolInstaller { } } } + +#if DEBUG +struct ToolsSettings_Previews: PreviewProvider { + static var previews: some View { + ToolsSettings() + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 3791c3031..41150eecb 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -1,6 +1,12 @@ import AppKit import Foundation +extension ProcessInfo { + var isPreview: Bool { + self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } +} + enum LaunchdManager { private static func runLaunchctl(_ args: [String]) { let process = Process() diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift index e8100a92f..13bbebb10 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -17,6 +17,7 @@ struct VoiceWakeSettings: View { @State private var availableLocales: [Locale] = [] private let fieldLabelWidth: CGFloat = 120 private let controlWidth: CGFloat = 240 + private let isPreview = ProcessInfo.processInfo.isPreview private struct AudioInputDevice: Identifiable, Equatable { let uid: String @@ -83,13 +84,24 @@ struct VoiceWakeSettings: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) } - .task { await self.loadMicsIfNeeded() } - .task { await self.loadLocalesIfNeeded() } - .task { await self.restartMeter() } + .task { + guard !self.isPreview else { return } + await self.loadMicsIfNeeded() + } + .task { + guard !self.isPreview else { return } + await self.loadLocalesIfNeeded() + } + .task { + guard !self.isPreview else { return } + await self.restartMeter() + } .onChange(of: self.state.voiceWakeMicID) { _, _ in + guard !self.isPreview else { return } Task { await self.restartMeter() } } .onDisappear { + guard !self.isPreview else { return } Task { await self.meter.stop() } } } @@ -489,3 +501,12 @@ struct VoiceWakeSettings: View { } } } + +#if DEBUG +struct VoiceWakeSettings_Previews: PreviewProvider { + static var previews: some View { + VoiceWakeSettings(state: .preview) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif