refactor(observation): migrate SwiftUI state

main
Peter Steinberger 2025-12-14 05:04:58 +00:00
parent aab5c490dc
commit b48a556de5
43 changed files with 253 additions and 194 deletions

View File

@ -1,18 +1,18 @@
import ClawdisKit import ClawdisKit
import Combine
import Foundation import Foundation
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
final class BridgeConnectionController: ObservableObject { @Observable
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = [] final class BridgeConnectionController {
@Published private(set) var discoveryStatusText: String = "Idle" private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = [] private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel() private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel? private weak var appModel: NodeAppModel?
private var cancellables = Set<AnyCancellable>()
private var didAutoConnect = false private var didAutoConnect = false
private var seenStableIDs = Set<String>() private var seenStableIDs = Set<String>()
@ -23,20 +23,8 @@ final class BridgeConnectionController: ObservableObject {
self.discovery.setDebugLoggingEnabled( self.discovery.setDebugLoggingEnabled(
UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs")) UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.$bridges self.updateFromDiscovery()
.sink { [weak self] newValue in self.observeDiscovery()
guard let self else { return }
self.bridges = newValue
self.updateLastDiscoveredBridge(from: newValue)
self.maybeAutoConnect()
}
.store(in: &self.cancellables)
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
self.discovery.$debugLog
.assign(to: &self.$discoveryDebugLog)
if startDiscovery { if startDiscovery {
self.discovery.start() self.discovery.start()
@ -58,6 +46,29 @@ final class BridgeConnectionController: ObservableObject {
} }
} }
private func updateFromDiscovery() {
let newBridges = self.discovery.bridges
self.bridges = newBridges
self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges)
self.maybeAutoConnect()
}
private func observeDiscovery() {
withObservationTracking {
_ = self.discovery.bridges
_ = self.discovery.statusText
_ = self.discovery.debugLog
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.updateFromDiscovery()
self.observeDiscovery()
}
}
}
private func maybeAutoConnect() { private func maybeAutoConnect() {
guard !self.didAutoConnect else { return } guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return } guard let appModel = self.appModel else { return }

View File

@ -2,7 +2,7 @@ import SwiftUI
import UIKit import UIKit
struct BridgeDiscoveryDebugLogView: View { struct BridgeDiscoveryDebugLogView: View {
@EnvironmentObject private var bridgeController: BridgeConnectionController @Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false @AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View { var body: some View {

View File

@ -1,9 +1,11 @@
import ClawdisKit import ClawdisKit
import Foundation import Foundation
import Network import Network
import Observation
@MainActor @MainActor
final class BridgeDiscoveryModel: ObservableObject { @Observable
final class BridgeDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable { struct DebugLogEntry: Identifiable, Equatable {
var id = UUID() var id = UUID()
var ts: Date var ts: Date
@ -18,9 +20,9 @@ final class BridgeDiscoveryModel: ObservableObject {
var debugID: String var debugID: String
} }
@Published var bridges: [DiscoveredBridge] = [] var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle" var statusText: String = "Idle"
@Published private(set) var debugLog: [DebugLogEntry] = [] private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser? private var browser: NWBrowser?
private var debugLoggingEnabled = false private var debugLoggingEnabled = false

View File

@ -3,12 +3,12 @@ import SwiftUI
struct ChatSheet: View { struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel @State private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") { init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject( self._viewModel = State(
wrappedValue: ClawdisChatViewModel( initialValue: ClawdisChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,
transport: transport)) transport: transport))
} }

View File

@ -2,23 +2,23 @@ import SwiftUI
@main @main
struct ClawdisApp: App { struct ClawdisApp: App {
@StateObject private var appModel: NodeAppModel @State private var appModel: NodeAppModel
@StateObject private var bridgeController: BridgeConnectionController @State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
BridgeSettingsStore.bootstrapPersistence() BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel() let appModel = NodeAppModel()
_appModel = StateObject(wrappedValue: appModel) _appModel = State(initialValue: appModel)
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel)) _bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootCanvas() RootCanvas()
.environmentObject(self.appModel) .environment(self.appModel)
.environmentObject(self.appModel.voiceWake) .environment(self.appModel.voiceWake)
.environmentObject(self.bridgeController) .environment(self.bridgeController)
.onOpenURL { url in .onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) } Task { await self.appModel.handleDeepLink(url: url) }
} }

View File

@ -1,16 +1,18 @@
import ClawdisKit import ClawdisKit
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
final class NodeAppModel: ObservableObject { @Observable
@Published var isBackgrounded: Bool = false final class NodeAppModel {
var isBackgrounded: Bool = false
let screen = ScreenController() let screen = ScreenController()
let camera = CameraController() let camera = CameraController()
@Published var bridgeStatusText: String = "Not connected" var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String? var bridgeServerName: String?
@Published var bridgeRemoteAddress: String? var bridgeRemoteAddress: String?
@Published var connectedBridgeID: String? var connectedBridgeID: String?
private let bridge = BridgeSession() private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>? private var bridgeTask: Task<Void, Never>?

View File

@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
struct RootCanvas: View { struct RootCanvas: View {
@EnvironmentObject private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel
@EnvironmentObject private var voiceWake: VoiceWakeManager @Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var presentedSheet: PresentedSheet? @State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String? @State private var voiceWakeToastText: String?

View File

@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
struct RootTabs: View { struct RootTabs: View {
@EnvironmentObject private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel
@EnvironmentObject private var voiceWake: VoiceWakeManager @Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0 @State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String? @State private var voiceWakeToastText: String?

View File

@ -1,14 +1,16 @@
import ClawdisKit import ClawdisKit
import Observation
import SwiftUI import SwiftUI
import WebKit import WebKit
@MainActor @MainActor
final class ScreenController: ObservableObject { @Observable
final class ScreenController {
let webView: WKWebView let webView: WKWebView
@Published var mode: ClawdisScreenMode = .canvas var mode: ClawdisScreenMode = .canvas
@Published var urlString: String = "" var urlString: String = ""
@Published var errorText: String? var errorText: String?
init() { init() {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()

View File

@ -2,7 +2,7 @@ import ClawdisKit
import SwiftUI import SwiftUI
struct ScreenTab: View { struct ScreenTab: View {
@EnvironmentObject private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel
var body: some View { var body: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {

View File

@ -3,7 +3,7 @@ import SwiftUI
import WebKit import WebKit
struct ScreenWebView: UIViewRepresentable { struct ScreenWebView: UIViewRepresentable {
@ObservedObject var controller: ScreenController var controller: ScreenController
func makeUIView(context: Context) -> WKWebView { func makeUIView(context: Context) -> WKWebView {
self.controller.webView self.controller.webView

View File

@ -1,19 +1,21 @@
import ClawdisKit import ClawdisKit
import Network import Network
import Observation
import SwiftUI import SwiftUI
import UIKit import UIKit
@MainActor @MainActor
private final class ConnectStatusStore: ObservableObject { @Observable
@Published var text: String? private final class ConnectStatusStore {
var text: String?
} }
extension ConnectStatusStore: @unchecked Sendable {} extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View { struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@EnvironmentObject private var bridgeController: BridgeConnectionController @Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@ -25,7 +27,7 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@StateObject private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingBridgeID: String?
@State private var localIPAddress: String? @State private var localIPAddress: String?
@ -264,6 +266,7 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil } defer { self.connectingBridgeID = nil }
do { do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge", service: "com.steipete.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
@ -281,9 +284,8 @@ struct SettingsTab: View {
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
hello: hello, hello: hello,
onStatus: { status in onStatus: { status in
let store = self.connectStatus
Task { @MainActor in Task { @MainActor in
store.text = status statusStore.text = status
} }
}) })
@ -330,6 +332,7 @@ struct SettingsTab: View {
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
do { do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge", service: "com.steipete.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
@ -347,9 +350,8 @@ struct SettingsTab: View {
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onStatus: { status in onStatus: { status in
let store = self.connectStatus
Task { @MainActor in Task { @MainActor in
store.text = status statusStore.text = status
} }
}) })

View File

@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
struct VoiceTab: View { struct VoiceTab: View {
@EnvironmentObject private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel
@EnvironmentObject private var voiceWake: VoiceWakeManager @Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
var body: some View { var body: some View {

View File

@ -1,5 +1,6 @@
import AVFAudio import AVFAudio
import Foundation import Foundation
import Observation
import Speech import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
@ -76,12 +77,13 @@ extension AVAudioPCMBuffer {
} }
@MainActor @MainActor
final class VoiceWakeManager: NSObject, ObservableObject { @Observable
@Published var isEnabled: Bool = false final class VoiceWakeManager: NSObject {
@Published var isListening: Bool = false var isEnabled: Bool = false
@Published var statusText: String = "Off" var isListening: Bool = false
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() var statusText: String = "Off"
@Published var lastTriggeredCommand: String? var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
var lastTriggeredCommand: String?
private let audioEngine = AVAudioEngine() private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer? private var speechRecognizer: SFSpeechRecognizer?

View File

@ -1,10 +1,12 @@
import Foundation import Foundation
import Observation
@MainActor @MainActor
final class AgentEventStore: ObservableObject { @Observable
final class AgentEventStore {
static let shared = AgentEventStore() static let shared = AgentEventStore()
@Published private(set) var events: [ControlAgentEvent] = [] private(set) var events: [ControlAgentEvent] = []
private let maxEvents = 400 private let maxEvents = 400
func append(_ event: ControlAgentEvent) { func append(_ event: ControlAgentEvent) {

View File

@ -2,7 +2,7 @@ import SwiftUI
@MainActor @MainActor
struct AgentEventsWindow: View { struct AgentEventsWindow: View {
@ObservedObject private var store = AgentEventStore.shared private let store = AgentEventStore.shared
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {

View File

@ -1,10 +1,12 @@
import AppKit import AppKit
import Foundation import Foundation
import Observation
import ServiceManagement import ServiceManagement
import SwiftUI import SwiftUI
@MainActor @MainActor
final class AppState: ObservableObject { @Observable
final class AppState {
private let isPreview: Bool private let isPreview: Bool
private var suppressVoiceWakeGlobalSync = false private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>? private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@ -19,26 +21,26 @@ final class AppState: ObservableObject {
case remote case remote
} }
@Published var isPaused: Bool { var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
} }
@Published var launchAtLogin: Bool { var launchAtLogin: Bool {
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } } didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
} }
@Published var onboardingSeen: Bool { var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") } didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
} }
} }
@Published var debugPaneEnabled: Bool { var debugPaneEnabled: Bool {
didSet { didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
} }
} }
@Published var swabbleEnabled: Bool { var swabbleEnabled: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
@ -47,7 +49,7 @@ final class AppState: ObservableObject {
} }
} }
@Published var swabbleTriggerWords: [String] { var swabbleTriggerWords: [String] {
didSet { didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers. // Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview { self.ifNotPreview {
@ -60,21 +62,21 @@ final class AppState: ObservableObject {
} }
} }
@Published var voiceWakeTriggerChime: VoiceWakeChime { var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } } didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
} }
@Published var voiceWakeSendChime: VoiceWakeChime { var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } } didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
} }
@Published var iconAnimationsEnabled: Bool { var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set( didSet { self.ifNotPreview { UserDefaults.standard.set(
self.iconAnimationsEnabled, self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } } forKey: iconAnimationsEnabledKey) } }
} }
@Published var showDockIcon: Bool { var showDockIcon: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
@ -83,7 +85,7 @@ final class AppState: ObservableObject {
} }
} }
@Published var voiceWakeMicID: String { var voiceWakeMicID: String {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
@ -94,7 +96,7 @@ final class AppState: ObservableObject {
} }
} }
@Published var voiceWakeLocaleID: String { var voiceWakeLocaleID: String {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
@ -105,27 +107,27 @@ final class AppState: ObservableObject {
} }
} }
@Published var voiceWakeAdditionalLocaleIDs: [String] { var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set( didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs, self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } } forKey: voiceWakeAdditionalLocalesKey) } }
} }
@Published var voicePushToTalkEnabled: Bool { var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set( didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voicePushToTalkEnabled, self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } } forKey: voicePushToTalkEnabledKey) } }
} }
@Published var iconOverride: IconOverrideSelection { var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
} }
@Published var isWorking: Bool = false var isWorking: Bool = false
@Published var earBoostActive: Bool = false var earBoostActive: Bool = false
@Published var blinkTick: Int = 0 var blinkTick: Int = 0
@Published var sendCelebrationTick: Int = 0 var sendCelebrationTick: Int = 0
@Published var heartbeatsEnabled: Bool { var heartbeatsEnabled: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
@ -134,31 +136,31 @@ final class AppState: ObservableObject {
} }
} }
@Published var connectionMode: ConnectionMode { var connectionMode: ConnectionMode {
didSet { didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
} }
} }
@Published var webChatEnabled: Bool { var webChatEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
} }
@Published var webChatSwiftUIEnabled: Bool { var webChatSwiftUIEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set( didSet { self.ifNotPreview { UserDefaults.standard.set(
self.webChatSwiftUIEnabled, self.webChatSwiftUIEnabled,
forKey: webChatSwiftUIEnabledKey) } } forKey: webChatSwiftUIEnabledKey) } }
} }
@Published var webChatPort: Int { var webChatPort: Int {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
} }
@Published var canvasEnabled: Bool { var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
@Published var peekabooBridgeEnabled: Bool { var peekabooBridgeEnabled: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey) UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
@ -167,7 +169,7 @@ final class AppState: ObservableObject {
} }
} }
@Published var attachExistingGatewayOnly: Bool { var attachExistingGatewayOnly: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey) UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
@ -175,15 +177,15 @@ final class AppState: ObservableObject {
} }
} }
@Published var remoteTarget: String { var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
} }
@Published var remoteIdentity: String { var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
} }
@Published var remoteProjectRoot: String { var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
} }

View File

@ -1,5 +1,6 @@
import ClawdisProtocol import ClawdisProtocol
import Foundation import Foundation
import Observation
import OSLog import OSLog
import SwiftUI import SwiftUI
@ -36,7 +37,8 @@ enum ControlChannelError: Error, LocalizedError {
} }
@MainActor @MainActor
final class ControlChannel: ObservableObject { @Observable
final class ControlChannel {
static let shared = ControlChannel() static let shared = ControlChannel()
enum Mode { enum Mode {
@ -51,8 +53,8 @@ final class ControlChannel: ObservableObject {
case degraded(String) case degraded(String)
} }
@Published private(set) var state: ConnectionState = .disconnected private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double? private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")

View File

@ -1,23 +1,25 @@
import ClawdisProtocol import ClawdisProtocol
import Foundation import Foundation
import Observation
import OSLog import OSLog
@MainActor @MainActor
final class CronJobsStore: ObservableObject { @Observable
final class CronJobsStore {
static let shared = CronJobsStore() static let shared = CronJobsStore()
@Published var jobs: [CronJob] = [] var jobs: [CronJob] = []
@Published var selectedJobId: String? var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = [] var runEntries: [CronRunLogEntry] = []
@Published var schedulerEnabled: Bool? var schedulerEnabled: Bool?
@Published var schedulerStorePath: String? var schedulerStorePath: String?
@Published var schedulerNextWakeAtMs: Int? var schedulerNextWakeAtMs: Int?
@Published var isLoadingJobs = false var isLoadingJobs = false
@Published var isLoadingRuns = false var isLoadingRuns = false
@Published var lastError: String? var lastError: String?
@Published var statusMessage: String? var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?

View File

@ -1,7 +1,8 @@
import Observation
import SwiftUI import SwiftUI
struct CronSettings: View { struct CronSettings: View {
@ObservedObject var store: CronJobsStore @Bindable var store: CronJobsStore
@State private var showEditor = false @State private var showEditor = false
@State private var editingJob: CronJob? @State private var editingJob: CronJob?
@State private var editorError: String? @State private var editorError: String?

View File

@ -12,8 +12,8 @@ struct DebugSettings: View {
@State private var modelsCount: Int? @State private var modelsCount: Int?
@State private var modelsLoading = false @State private var modelsLoading = false
@State private var modelsError: String? @State private var modelsError: String?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared private let healthStore = HealthStore.shared
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath() @State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath @State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String? @State private var sessionStoreSaveError: String?

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import Network import Network
import Observation
import OSLog import OSLog
import Subprocess import Subprocess
#if canImport(Darwin) #if canImport(Darwin)
@ -12,7 +13,8 @@ import SystemPackage
#endif #endif
@MainActor @MainActor
final class GatewayProcessManager: ObservableObject { @Observable
final class GatewayProcessManager {
static let shared = GatewayProcessManager() static let shared = GatewayProcessManager()
enum Status: Equatable { enum Status: Equatable {
@ -39,11 +41,11 @@ final class GatewayProcessManager: ObservableObject {
} }
} }
@Published private(set) var status: Status = .stopped private(set) var status: Status = .stopped
@Published private(set) var log: String = "" private(set) var log: String = ""
@Published private(set) var restartCount: Int = 0 private(set) var restartCount: Int = 0
@Published private(set) var environmentStatus: GatewayEnvironmentStatus = .checking private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
@Published private(set) var existingGatewayDetails: String? private(set) var existingGatewayDetails: String?
private var execution: Execution? private var execution: Execution?
private var lastPid: Int32? private var lastPid: Int32?

View File

@ -1,13 +1,14 @@
import AppKit import AppKit
import Observation
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@ObservedObject var state: AppState @Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
@ObservedObject private var healthStore = HealthStore.shared private let healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel() @State private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false @State private var isInstallingCLI = false
@State private var cliStatus: String? @State private var cliStatus: String?
@State private var cliInstalled = false @State private var cliInstalled = false

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import Network import Network
import Observation
import OSLog import OSLog
import SwiftUI import SwiftUI
@ -53,15 +54,16 @@ enum HealthState: Equatable {
} }
@MainActor @MainActor
final class HealthStore: ObservableObject { @Observable
final class HealthStore {
static let shared = HealthStore() static let shared = HealthStore()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health") private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
@Published private(set) var snapshot: HealthSnapshot? private(set) var snapshot: HealthSnapshot?
@Published private(set) var lastSuccess: Date? private(set) var lastSuccess: Date?
@Published private(set) var lastError: String? private(set) var lastError: String?
@Published private(set) var isRefreshing = false private(set) var isRefreshing = false
private var loopTask: Task<Void, Never>? private var loopTask: Task<Void, Never>?
private let refreshInterval: TimeInterval = 60 private let refreshInterval: TimeInterval = 60

View File

@ -1,11 +1,13 @@
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
final class HeartbeatStore: ObservableObject { @Observable
final class HeartbeatStore {
static let shared = HeartbeatStore() static let shared = HeartbeatStore()
@Published private(set) var lastEvent: ControlHeartbeatEvent? private(set) var lastEvent: ControlHeartbeatEvent?
private var observer: NSObjectProtocol? private var observer: NSObjectProtocol?

View File

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct InstancesSettings: View { struct InstancesSettings: View {
@ObservedObject var store: InstancesStore var store: InstancesStore
init(store: InstancesStore = .shared) { init(store: InstancesStore = .shared) {
self.store = store self.store = store

View File

@ -1,6 +1,7 @@
import ClawdisProtocol import ClawdisProtocol
import Cocoa import Cocoa
import Foundation import Foundation
import Observation
import OSLog import OSLog
struct InstanceInfo: Identifiable, Codable { struct InstanceInfo: Identifiable, Codable {
@ -27,14 +28,15 @@ struct InstanceInfo: Identifiable, Codable {
} }
@MainActor @MainActor
final class InstancesStore: ObservableObject { @Observable
final class InstancesStore {
static let shared = InstancesStore() static let shared = InstancesStore()
let isPreview: Bool let isPreview: Bool
@Published var instances: [InstanceInfo] = [] var instances: [InstanceInfo] = []
@Published var lastError: String? var lastError: String?
@Published var statusMessage: String? var statusMessage: String?
@Published var isLoading = false var isLoading = false
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
private var task: Task<Void, Never>? private var task: Task<Void, Never>?

View File

@ -3,7 +3,7 @@ import SwiftUI
// master is part of the discovery protocol naming; keep UI components consistent. // master is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View { struct MasterDiscoveryInlineList: View {
@ObservedObject var discovery: MasterDiscoveryModel var discovery: MasterDiscoveryModel
var currentTarget: String? var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredMasterID: MasterDiscoveryModel.DiscoveredMaster.ID? @State private var hoveredMasterID: MasterDiscoveryModel.DiscoveredMaster.ID?
@ -109,7 +109,7 @@ struct MasterDiscoveryInlineList: View {
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View { struct MasterDiscoveryMenu: View {
@ObservedObject var discovery: MasterDiscoveryModel var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
var body: some View { var body: some View {

View File

@ -1,10 +1,12 @@
import Foundation import Foundation
import Network import Network
import Observation
// We use master as the on-the-wire service name; keep the model aligned with the protocol/docs. // We use master as the on-the-wire service name; keep the model aligned with the protocol/docs.
@MainActor @MainActor
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
final class MasterDiscoveryModel: ObservableObject { @Observable
final class MasterDiscoveryModel {
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
struct DiscoveredMaster: Identifiable, Equatable { struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID } var id: String { self.debugID }
@ -16,8 +18,8 @@ final class MasterDiscoveryModel: ObservableObject {
} }
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
@Published var masters: [DiscoveredMaster] = [] var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle" var statusText: String = "Idle"
private var browser: NWBrowser? private var browser: NWBrowser?

View File

@ -9,9 +9,9 @@ import SwiftUI
@main @main
struct ClawdisApp: App { struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState @State private var state: AppState
@StateObject private var gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isPanelVisible = false @State private var isPanelVisible = false
@ -23,7 +23,7 @@ struct ClawdisApp: App {
} }
init() { init() {
_state = StateObject(wrappedValue: AppStateStore.shared) _state = State(initialValue: AppStateStore.shared)
} }
var body: some Scene { var body: some Scene {

View File

@ -1,17 +1,18 @@
import AppKit import AppKit
import AVFoundation import AVFoundation
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
/// Menu contents for the Clawdis menu bar extra. /// Menu contents for the Clawdis menu bar extra.
struct MenuContent: View { struct MenuContent: View {
@ObservedObject var state: AppState @Bindable var state: AppState
let updater: UpdaterProviding? let updater: UpdaterProviding?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared private let healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared private let heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared private let controlChannel = ControlChannel.shared
@ObservedObject private var activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false

View File

@ -1,13 +1,15 @@
import AppKit import AppKit
import Observation
import QuartzCore import QuartzCore
import SwiftUI import SwiftUI
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center). /// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
@MainActor @MainActor
final class NotifyOverlayController: ObservableObject { @Observable
final class NotifyOverlayController {
static let shared = NotifyOverlayController() static let shared = NotifyOverlayController()
@Published private(set) var model = Model() private(set) var model = Model()
var isVisible: Bool { self.model.isVisible } var isVisible: Bool { self.model.isVisible }
struct Model { struct Model {
@ -159,7 +161,7 @@ final class NotifyOverlayController: ObservableObject {
} }
private struct NotifyOverlayView: View { private struct NotifyOverlayView: View {
@ObservedObject var controller: NotifyOverlayController var controller: NotifyOverlayController
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {

View File

@ -1,5 +1,6 @@
import AppKit import AppKit
import ClawdisIPC import ClawdisIPC
import Observation
import SwiftUI import SwiftUI
enum UIStrings { enum UIStrings {
@ -68,9 +69,9 @@ struct OnboardingView: View {
@State private var gatewayInstalling = false @State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String? @State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery: MasterDiscoveryModel @State private var masterDiscovery: MasterDiscoveryModel
@ObservedObject private var state: AppState @Bindable private var state: AppState
@ObservedObject private var permissionMonitor: PermissionMonitor private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 680 private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520 private let contentHeight: CGFloat = 520
@ -99,9 +100,9 @@ struct OnboardingView: View {
permissionMonitor: PermissionMonitor = .shared, permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel()) discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
{ {
self._state = ObservedObject(wrappedValue: state) self.state = state
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor) self.permissionMonitor = permissionMonitor
self._masterDiscovery = StateObject(wrappedValue: discoveryModel) self._masterDiscovery = State(initialValue: discoveryModel)
} }
var body: some View { var body: some View {

View File

@ -4,6 +4,7 @@ import AVFoundation
import ClawdisIPC import ClawdisIPC
import CoreGraphics import CoreGraphics
import Foundation import Foundation
import Observation
import OSLog import OSLog
import Speech import Speech
import UserNotifications import UserNotifications
@ -236,10 +237,11 @@ enum AppleScriptPermission {
} }
@MainActor @MainActor
final class PermissionMonitor: ObservableObject { @Observable
final class PermissionMonitor {
static let shared = PermissionMonitor() static let shared = PermissionMonitor()
@Published private(set) var status: [Capability: Bool] = [:] private(set) var status: [Capability: Bool] = [:]
private var monitorTimer: Timer? private var monitorTimer: Timer?
private var isChecking = false private var isChecking = false

View File

@ -4,7 +4,7 @@ import SwiftUI
@MainActor @MainActor
struct SessionsSettings: View { struct SessionsSettings: View {
private let isPreview: Bool private let isPreview: Bool
@ObservedObject private var state = AppStateStore.shared private let state = AppStateStore.shared
@State private var rows: [SessionRow] @State private var rows: [SessionRow]
@State private var storePath: String = SessionLoader.defaultStorePath @State private var storePath: String = SessionLoader.defaultStorePath
@State private var lastLoaded: Date? @State private var lastLoaded: Date?

View File

@ -1,8 +1,9 @@
import Observation
import SwiftUI import SwiftUI
struct SettingsRootView: View { struct SettingsRootView: View {
@ObservedObject var state: AppState @Bindable var state: AppState
@ObservedObject private var permissionMonitor = PermissionMonitor.shared private let permissionMonitor = PermissionMonitor.shared
@State private var monitoringPermissions = false @State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general @State private var selectedTab: SettingsTab = .general
let updater: UpdaterProviding? let updater: UpdaterProviding?

View File

@ -1,9 +1,11 @@
import AppKit import AppKit
import Foundation import Foundation
import Observation
import OSLog import OSLog
@MainActor @MainActor
final class VoiceSessionCoordinator: ObservableObject { @Observable
final class VoiceSessionCoordinator {
static let shared = VoiceSessionCoordinator() static let shared = VoiceSessionCoordinator()
enum Source: String { case wakeWord, pushToTalk } enum Source: String { case wakeWord, pushToTalk }

View File

@ -1,11 +1,13 @@
import AppKit import AppKit
import Observation
import OSLog import OSLog
import QuartzCore import QuartzCore
import SwiftUI import SwiftUI
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. /// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
@MainActor @MainActor
final class VoiceWakeOverlayController: ObservableObject { @Observable
final class VoiceWakeOverlayController {
static let shared = VoiceWakeOverlayController() static let shared = VoiceWakeOverlayController()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
@ -17,7 +19,7 @@ final class VoiceWakeOverlayController: ObservableObject {
enum Source: String { case wakeWord, pushToTalk } enum Source: String { case wakeWord, pushToTalk }
@Published private(set) var model = Model() private(set) var model = Model()
var isVisible: Bool { self.model.isVisible } var isVisible: Bool { self.model.isVisible }
struct Model { struct Model {
@ -465,7 +467,7 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
struct VoiceWakeOverlayView: View { struct VoiceWakeOverlayView: View {
@ObservedObject var controller: VoiceWakeOverlayController var controller: VoiceWakeOverlayController
@FocusState private var textFocused: Bool @FocusState private var textFocused: Bool
@State private var isHovering: Bool = false @State private var isHovering: Bool = false
@State private var closeHovering: Bool = false @State private var closeHovering: Bool = false

View File

@ -1,11 +1,12 @@
import AppKit import AppKit
import AVFoundation import AVFoundation
import Observation
import Speech import Speech
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct VoiceWakeSettings: View { struct VoiceWakeSettings: View {
@ObservedObject var state: AppState @Bindable var state: AppState
@State private var testState: VoiceWakeTestState = .idle @State private var testState: VoiceWakeTestState = .idle
@State private var tester = VoiceWakeTester() @State private var tester = VoiceWakeTester()
@State private var isTesting = false @State private var isTesting = false

View File

@ -1,8 +1,10 @@
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
final class WorkActivityStore: ObservableObject { @Observable
final class WorkActivityStore {
static let shared = WorkActivityStore() static let shared = WorkActivityStore()
struct Activity: Equatable { struct Activity: Equatable {
@ -14,8 +16,8 @@ final class WorkActivityStore: ObservableObject {
var lastUpdate: Date var lastUpdate: Date
} }
@Published private(set) var current: Activity? private(set) var current: Activity?
@Published private(set) var iconState: IconState = .idle private(set) var iconState: IconState = .idle
private var active: [String: Activity] = [:] private var active: [String: Activity] = [:]
private var currentSessionKey: String? private var currentSessionKey: String?

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
#if !os(macOS) #if !os(macOS)
@ -8,7 +9,7 @@ import UniformTypeIdentifiers
@MainActor @MainActor
struct ClawdisChatComposer: View { struct ClawdisChatComposer: View {
@ObservedObject var viewModel: ClawdisChatViewModel @Bindable var viewModel: ClawdisChatViewModel
#if !os(macOS) #if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = [] @State private var pickerItems: [PhotosPickerItem] = []

View File

@ -2,11 +2,11 @@ import SwiftUI
@MainActor @MainActor
public struct ClawdisChatView: View { public struct ClawdisChatView: View {
@StateObject private var viewModel: ClawdisChatViewModel @State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID() @State private var scrollerBottomID = UUID()
public init(viewModel: ClawdisChatViewModel) { public init(viewModel: ClawdisChatViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel) self._viewModel = State(initialValue: viewModel)
} }
public var body: some View { public var body: some View {

View File

@ -1,5 +1,6 @@
import ClawdisKit import ClawdisKit
import Foundation import Foundation
import Observation
import OSLog import OSLog
import UniformTypeIdentifiers import UniformTypeIdentifiers
@ -12,21 +13,23 @@ import UIKit
private let chatUILogger = Logger(subsystem: "com.steipete.clawdis", category: "ClawdisChatUI") private let chatUILogger = Logger(subsystem: "com.steipete.clawdis", category: "ClawdisChatUI")
@MainActor @MainActor
public final class ClawdisChatViewModel: ObservableObject { @Observable
@Published public private(set) var messages: [ClawdisChatMessage] = [] public final class ClawdisChatViewModel {
@Published public var input: String = "" public private(set) var messages: [ClawdisChatMessage] = []
@Published public var thinkingLevel: String = "off" public var input: String = ""
@Published public private(set) var isLoading = false public var thinkingLevel: String = "off"
@Published public private(set) var isSending = false public private(set) var isLoading = false
@Published public var errorText: String? public private(set) var isSending = false
@Published public var attachments: [ClawdisPendingAttachment] = [] public var errorText: String?
@Published public private(set) var healthOK: Bool = true public var attachments: [ClawdisPendingAttachment] = []
@Published public private(set) var pendingRunCount: Int = 0 public private(set) var healthOK: Bool = true
public private(set) var pendingRunCount: Int = 0
public let sessionKey: String public let sessionKey: String
private let transport: any ClawdisChatTransport private let transport: any ClawdisChatTransport
private var eventTask: Task<Void, Never>? @ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
private var pendingRuns = Set<String>() { private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count } didSet { self.pendingRunCount = self.pendingRuns.count }
} }