diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 42b0cbf0..6931ef21 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -44,6 +44,14 @@ public protocol HookController: AnyObject { /// Whether the thread should skip all lifecycle hooks (e.g. a review thread). func threadSkipsHooks(sessionId: String) -> Bool + /// Whether session-end lifecycle hooks must be skipped for the current turn + /// because it is a *planning* turn — the session is in plan mode, or the + /// agent emitted an `ExitPlanMode` plan the user hasn't decided yet. Code + /// review, commit/push, and every other stop hook must not run on a plan + /// (reviewing/committing an unaccepted plan is always wrong). Enforced + /// centrally in `HookManager` ahead of both the completion and cancellation + /// session-end dispatch paths. + func sessionEndHooksSuppressed(sessionKey: String, sessionId: String) -> Bool /// Resolve a stored hook model selection into the provider/model pair used /// for a spawned agent thread. Empty selections inherit the reviewed thread. func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)? diff --git a/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift b/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift index 47872e09..1e6eecba 100644 --- a/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift +++ b/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift @@ -19,6 +19,17 @@ public enum AppSupport { return target }() + public static let personalWorkspaceID = "personal" + + public static func workspaceScopedURL(id: String) -> URL { + if id == personalWorkspaceID { + return bundleScopedURL + } + return bundleScopedURL + .appendingPathComponent("workspaces", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + } + private static var directoryName: String { let bundleID = Bundle.main.bundleIdentifier ?? "com.idealapp.RxCode" if bundleID == "com.idealapp.RxCode" { return "RxCode" } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index c22bd56e..5f833b5a 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; }; DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; + FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; @@ -154,6 +155,7 @@ DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = ""; }; DF5B0DDE2FCB300100CE36F /* MobileUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MobileUnitTestPlan.xctestplan; sourceTree = ""; }; DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; + FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = ""; }; E62000002FCB000100000001 /* MemoryIntentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryIntentTests.swift; sourceTree = ""; }; @@ -316,11 +318,11 @@ DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */, DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */, DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */, - DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */, - E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, - E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */, - DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, - E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, + DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */, + E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, + E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */, + DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, + E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */, @@ -342,6 +344,7 @@ 7A5C0001000000000000A001 /* MockAgentBackend.swift */, 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */, DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */, + FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */, DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */, DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */, 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */, @@ -618,11 +621,11 @@ packageProductDependencies = ( E6A001012F8A000100000001 /* SwiftTerm */, E6C001012F9B000100000001 /* Sparkle */, - E6D001012FA0000100000001 /* RxCodeCore */, - E6D001022FA0000100000001 /* RxCodeChatKit */, - E6D001082FA0000100000001 /* RxCodeEditor */, - E6D001062FA0000100000001 /* RxCodeSync */, - DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, + E6D001012FA0000100000001 /* RxCodeCore */, + E6D001022FA0000100000001 /* RxCodeChatKit */, + E6D001082FA0000100000001 /* RxCodeEditor */, + E6D001062FA0000100000001 /* RxCodeSync */, + DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, FB0000010000000000000001 /* FirebaseAnalytics */, FB0000020000000000000001 /* FirebaseCrashlytics */, DF4628912FC611E6002D9562 /* RxAuthSwift */, @@ -797,6 +800,7 @@ 7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */, 7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */, DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */, + FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */, DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */, DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */, 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */, diff --git a/RxCode/App/AppCore.swift b/RxCode/App/AppCore.swift new file mode 100644 index 00000000..1eb9bbd8 --- /dev/null +++ b/RxCode/App/AppCore.swift @@ -0,0 +1,38 @@ +import Foundation +import RxCodeCore + +/// Process-global state shared by every workspace. +/// +/// `AppState` is per-workspace (one instance per open workspace window), but a +/// handful of services must be shared across all of them: +/// +/// - `metaStore` / `cliStore` read & write **global** on-disk locations +/// (`~/.claude/projects`, `~/Library/Application Support/RxCode/session-meta`) +/// that are not workspace-scoped. Giving each workspace its own instance would +/// create incoherent in-memory caches over the same files. +/// - `claude` manages CLI subprocess lifecycle (PGIDs, stdin handles, descendant +/// trackers) keyed by stream id. A single shared instance keeps one registry of +/// running streams across all windows. Event routing stays correct because each +/// `AppState` owns its own `AsyncStream` iteration (pull model) — see +/// `AppState+CrossProject.processStream`. +/// - `workspaceRegistry` is the single source of truth for the workspace list and +/// the active-workspace selection, persisted to `workspaces.json`. +/// +/// One `AppCore` is created by `RxCodeApp` and handed to every `AppState`. +@Observable +@MainActor +final class AppCore { + let metaStore: SessionMetaStore + let cliStore: CLISessionStore + let claude: ClaudeService + let workspaceRegistry: WorkspaceRegistry + + init() { + let metaStore = SessionMetaStore() + let cliStore = CLISessionStore(metaStore: metaStore) + self.metaStore = metaStore + self.cliStore = cliStore + self.claude = ClaudeService(cliStore: cliStore) + self.workspaceRegistry = WorkspaceRegistry() + } +} diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift index 205e0eca..dac824d0 100644 --- a/RxCode/App/AppState+Agents.swift +++ b/RxCode/App/AppState+Agents.swift @@ -419,9 +419,15 @@ extension AppState { // MARK: - MCP Actions func refreshMCPServers() async { + let revision = mcpStateRevision + let mcp = mcp mcpIsLoading = true mcpListError = nil - defer { mcpIsLoading = false } + defer { + if revision == mcpStateRevision { + mcpIsLoading = false + } + } do { // Pass the active project so Settings can show global defaults plus // the effective per-project override state. @@ -447,8 +453,10 @@ extension AppState { merged.append(info) } } + guard revision == mcpStateRevision else { return } mcpServers = merged } catch { + guard revision == mcpStateRevision else { return } mcpListError = error.localizedDescription logger.error("MCP list failed: \(error.localizedDescription, privacy: .public)") } @@ -457,7 +465,9 @@ extension AppState { func probeMCPServer(name: String) async { guard let info = mcpServers.first(where: { $0.name == name }) else { // Fall back to a name-only probe if the row hasn't loaded yet. - await probeMCPServer(id: name, name: name, lookup: { await self.mcp.probe(name: name, projectPath: self.activeProjectPath) }) + let mcp = mcp + let activeProjectPath = activeProjectPath + await probeMCPServer(id: name, name: name, lookup: { await mcp.probe(name: name, projectPath: activeProjectPath) }) return } await probeMCPServer(info: info) @@ -467,16 +477,23 @@ extension AppState { /// multiple scopes/projects (aggregated Settings list) so the right /// configuration is resolved. func probeMCPServer(info: MCPServerInfo) async { - await probeMCPServer(id: info.id, name: info.name, lookup: { await self.mcp.probe(info: info) }) + let mcp = mcp + await probeMCPServer(id: info.id, name: info.name, lookup: { await mcp.probe(info: info) }) } func probeMCPServer(id: String, name: String, lookup: @escaping () async -> MCPProbeResult) async { + let revision = mcpStateRevision guard !mcpInFlightProbes.contains(id) else { return } mcpInFlightProbes.insert(id) - defer { mcpInFlightProbes.remove(id) } + defer { + if revision == mcpStateRevision { + mcpInFlightProbes.remove(id) + } + } let previousStatus: MCPStatus? = mcpServers.first(where: { $0.id == id })?.status let result = await lookup() + guard revision == mcpStateRevision else { return } mcpProbeResults[id] = result let newStatus: MCPStatus = result.ok diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift index 7e15cad6..8b77e7d8 100644 --- a/RxCode/App/AppState+Helpers.swift +++ b/RxCode/App/AppState+Helpers.swift @@ -9,9 +9,15 @@ extension AppState { // MARK: - Marketplace func loadMarketplace(forceRefresh: Bool = false) async { + let revision = marketplaceStateRevision + let marketplace = marketplace marketplaceLoading = true marketplaceSourceError = nil - defer { marketplaceLoading = false } + defer { + if revision == marketplaceStateRevision { + marketplaceLoading = false + } + } async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) async let installed = marketplace.installedPluginNames() @@ -20,9 +26,11 @@ extension AppState { let installedNames = await installed await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames) + let customSources = await marketplace.customSources() + guard revision == marketplaceStateRevision else { return } marketplaceCatalog = fetchedCatalog marketplaceInstalledNames = installedNames - marketplaceCustomSources = await marketplace.customSources() + marketplaceCustomSources = customSources } func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 264b1389..15c00f3d 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -133,20 +133,6 @@ extension AppState { ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment - // Supply MobileSyncService with desktop-side context for the mobile - // job Live Activity and home-screen widget pushes. - MobileSyncService.shared.projectNameResolver = { [weak self] id in - self?.projects.first { $0.id == id }?.name - } - MobileSyncService.shared.usageSnapshotProvider = { [weak self] in - ( - cc: self?.latestRateLimitUsage?.fiveHourPercent, - ccWeekly: self?.latestRateLimitUsage?.sevenDayPercent, - codex: self?.latestCodexRateLimitUsage?.fiveHourPercent, - codexWeekly: self?.latestCodexRateLimitUsage?.sevenDayPercent - ) - } - await refreshAgentInstallations() // Prewarm each backend's shell PATH cache in parallel so the first @@ -247,7 +233,7 @@ extension AppState { await loadACPClientsFromDisk() Task { await self.refreshACPRegistry(forceRefresh: false) } - permissionMode = Self.readPermissionModeFromSettings() + permissionMode = PermissionMode(rawValue: workspaceDefaults.string(for: "selectedPermissionMode") ?? "") ?? .default do { try await permission.start() @@ -430,7 +416,7 @@ extension AppState { let project = projects.first(where: { $0.id == projectId }) { selectProject(project, in: window) - } else if let savedId = UserDefaults.standard.string(forKey: "selectedProjectId"), + } else if let savedId = workspaceDefaults.string(for: "selectedProjectId"), let uuid = UUID(uuidString: savedId), let project = projects.first(where: { $0.id == uuid }) { diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 2dc2e0d6..a2ffe6fc 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -8,6 +8,37 @@ import SwiftUI // MARK: - Mobile Sync Bridge extension AppState { + /// Make this workspace the single owner of mobile sync: register the inbound + /// request observers and supply the desktop-side resolvers. Called by + /// `WorkspaceManager` when this workspace becomes frontmost. Idempotent. + func bindMobileSyncOwnership() { + guard mobileSyncObservers.isEmpty else { return } + setupMobileSyncBridge() + + // Supply MobileSyncService with desktop-side context for the mobile + // job Live Activity and home-screen widget pushes. + MobileSyncService.shared.projectNameResolver = { [weak self] id in + self?.projects.first { $0.id == id }?.name + } + MobileSyncService.shared.usageSnapshotProvider = { [weak self] in + ( + cc: self?.latestRateLimitUsage?.fiveHourPercent, + ccWeekly: self?.latestRateLimitUsage?.sevenDayPercent, + codex: self?.latestCodexRateLimitUsage?.fiveHourPercent, + codexWeekly: self?.latestCodexRateLimitUsage?.sevenDayPercent + ) + } + } + + /// Relinquish mobile-sync ownership: remove this workspace's inbound request + /// observers so it stops responding to mobile requests once another + /// workspace becomes frontmost. + func unbindMobileSyncOwnership() { + let center = NotificationCenter.default + mobileSyncObservers.forEach { center.removeObserver($0) } + mobileSyncObservers.removeAll() + } + func setupMobileSyncBridge() { let center = NotificationCenter.default let snapshotObserver = center.addObserver( diff --git a/RxCode/App/AppState+Model.swift b/RxCode/App/AppState+Model.swift index 3a311620..3b86f84f 100644 --- a/RxCode/App/AppState+Model.swift +++ b/RxCode/App/AppState+Model.swift @@ -11,8 +11,8 @@ extension AppState { static let availableModels = ["default", "best", "opus", "opus[1m]", "opusplan", "sonnet", "sonnet[1m]", "haiku"] static let fallbackCodexModels = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"] nonisolated static let defaultOpenAISummarizationEndpoint = "https://api.openai.com/v1" - static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization" - static let openAISummarizationKeychainAccount = "apiKey" + nonisolated static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization" + nonisolated static let openAISummarizationKeychainAccount = "apiKey" static var availableClaudeModels: [AgentModel] { availableModels.map { diff --git a/RxCode/App/AppState+PlanModeHooks.swift b/RxCode/App/AppState+PlanModeHooks.swift new file mode 100644 index 00000000..753a5e1d --- /dev/null +++ b/RxCode/App/AppState+PlanModeHooks.swift @@ -0,0 +1,44 @@ +import Foundation +import RxCodeChatKit +import RxCodeCore + +// MARK: - Plan-mode session-end hook suppression + +extension AppState { + /// Whether session-end lifecycle hooks (code review, commit/push, send + /// message, and user stop hooks) must be skipped for the current turn because + /// it is a *planning* turn: the session is in plan mode, or the agent emitted + /// an `ExitPlanMode` plan that the user has not decided yet. Acting on a plan + /// (reviewing or committing it) before the user accepts it is always wrong, so + /// this is enforced centrally in `HookManager` ahead of both the completion + /// and cancellation dispatch paths. + /// + /// The CLI rotates a session's id mid-life, so the stop dispatch may key the + /// state by either the resolved `sessionId` or the in-memory `sessionKey` — + /// both are tried. + func sessionEndHooksSuppressed(sessionKey: String, sessionId: String) -> Bool { + guard let state = sessionStates[sessionId] ?? sessionStates[sessionKey] else { return false } + if state.planMode { return true } + return Self.hasUndecidedExitPlan(in: state) + } + + /// True when the latest assistant run contains an `ExitPlanMode` tool call the + /// user hasn't accepted or rejected yet (the plan is "ready" and awaiting a + /// decision). Scans back only to the previous user turn so a plan decided in + /// an earlier turn doesn't keep stop hooks suppressed indefinitely. A decision + /// is recognized either from the tool result (the CLI's user-decision + /// follow-up) or the `planDecisionSummaries` sidecar, which survives CLI + /// session reloads that would overwrite the result. + static func hasUndecidedExitPlan(in state: SessionStreamState) -> Bool { + for message in state.messages.reversed() { + if message.role == .user { break } + for block in message.blocks { + guard let toolCall = block.toolCall, PlanLogic.isExitPlanMode(toolCall) else { continue } + let decidedViaResult = PlanLogic.isPlanDecided(toolCall) + let decidedViaSidecar = state.planDecisionSummaries[toolCall.id] != nil + if !decidedViaResult, !decidedViaSidecar { return true } + } + } + return false + } +} diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index bf9296ba..5600b4d3 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -86,7 +86,7 @@ extension AppState { activeProjectPath = project.path Task { await refreshMCPServers() } - UserDefaults.standard.set(project.id.uuidString, forKey: "selectedProjectId") + workspaceDefaults.set(project.id.uuidString, for: "selectedProjectId") } func addProjectFromFolder(_ url: URL, in window: WindowState) async { @@ -250,7 +250,7 @@ extension AppState { /// complete, kick off a background repo fetch). func onRxAuthSignedIn() { onboardingCompleted = true - UserDefaults.standard.set(true, forKey: "onboardingCompleted") + workspaceDefaults.set(true, for: "onboardingCompleted") startAutopilotWarmup() } diff --git a/RxCode/App/AppState+RateLimitUsage.swift b/RxCode/App/AppState+RateLimitUsage.swift new file mode 100644 index 00000000..9b7c100b --- /dev/null +++ b/RxCode/App/AppState+RateLimitUsage.swift @@ -0,0 +1,85 @@ +import Foundation +import RxCodeChatKit +import RxCodeCore + +// MARK: - Rate-Limit Usage + +extension AppState { + func cachedRateLimitUsage(for provider: AgentProvider) -> RateLimitUsage? { + switch provider { + case .claudeCode: + return latestRateLimitUsage + case .codex: + return latestCodexRateLimitUsage + case .acp: + return nil + } + } + + func rateLimitUsage(for provider: AgentProvider, forceRefresh: Bool = false) async -> RateLimitUsage? { + if !forceRefresh, let cached = cachedRateLimitUsage(for: provider) { + return cached + } + + if let task = rateLimitUsageRefreshTasks[provider] { + return await task.value ?? cachedRateLimitUsage(for: provider) + } + + let task = Task { [weak self] in + guard let self else { return nil } + switch provider { + case .claudeCode: + return await RateLimitService.shared.fetchUsage(forceRefresh: forceRefresh) + case .codex: + guard self.codexInstalled else { return nil } + return await self.codex.fetchRateLimits(forceRefresh: forceRefresh) + case .acp: + return nil + } + } + rateLimitUsageRefreshTasks[provider] = task + + let usage = await task.value + rateLimitUsageRefreshTasks[provider] = nil + + if let usage { + storeRateLimitUsage(usage, for: provider) + return usage + } + return cachedRateLimitUsage(for: provider) + } + + func storeRateLimitUsage(_ usage: RateLimitUsage, for provider: AgentProvider) { + switch provider { + case .claudeCode: + latestRateLimitUsage = usage + case .codex: + latestCodexRateLimitUsage = usage + case .acp: + break + } + // Refresh the mobile home-screen widget's usage figures. + MobileSyncService.shared.pushWidgetUpdate() + } + + /// Force-refresh the shared Claude rate-limit usage. + func refreshRateLimitUsage(forceRefresh: Bool = false) async { + _ = await rateLimitUsage(for: .claudeCode, forceRefresh: forceRefresh) + } + + /// Warm Codex usage early so the status bar can render Codex limits from cache. + func refreshCodexRateLimitUsage(forceRefresh: Bool = false) async { + _ = await rateLimitUsage(for: .codex, forceRefresh: forceRefresh) + } + + func refreshSelectedAgentRateLimitUsage(forceRefresh: Bool = false) async { + switch selectedAgentProvider { + case .claudeCode: + await refreshRateLimitUsage(forceRefresh: forceRefresh) + case .codex: + await refreshCodexRateLimitUsage(forceRefresh: forceRefresh) + case .acp: + break + } + } +} diff --git a/RxCode/App/AppState+Workspace.swift b/RxCode/App/AppState+Workspace.swift new file mode 100644 index 00000000..1366f9d4 --- /dev/null +++ b/RxCode/App/AppState+Workspace.swift @@ -0,0 +1,89 @@ +import Foundation +import RxCodeCore + +extension AppState { + /// Create a new workspace in the shared registry and return it. Does not + /// switch the current window — callers open a window for the new workspace + /// (each workspace lives in its own window with its own `AppState`). + @discardableResult + func createWorkspace(named name: String) -> AppWorkspace { + let snapshot = workspaceRegistry.createWorkspace(name: name) + workspaces = snapshot.all + return snapshot.active + } + + /// Persist `id` as the registry's active workspace (restored on next launch). + /// The window switch itself is performed by the caller via `openWindow`. + func activateWorkspace(id: String) { + guard let snapshot = workspaceRegistry.switchWorkspace(id: id) else { return } + workspaces = snapshot.all + } + + func renameWorkspace(id: String, to name: String) { + guard let snapshot = workspaceRegistry.renameWorkspace(id: id, name: name) else { return } + workspaces = snapshot.all + if id == activeWorkspace.id { + activeWorkspace = snapshot.all.first { $0.id == id } ?? activeWorkspace + } + } + + /// Remove a workspace from the shared registry. Closing its window and + /// discarding its cached `AppState` is handled by the caller (the view, via + /// `WorkspaceManager` / `dismissWindow`). + @discardableResult + func deleteWorkspace(id: String) -> Bool { + guard id != AppWorkspace.personalID else { return false } + guard let snapshot = workspaceRegistry.deleteWorkspace(id: id) else { return false } + workspaces = snapshot.all + return true + } + + func loadWorkspaceSettings() { + selectedTheme = AppTheme(rawValue: workspaceDefaults.string(for: "selectedTheme") ?? "") ?? .claude + fontSizeAdjustment = workspaceDefaults.int(for: "fontSizeAdjustment", default: 0) + messageFontSizeAdjustment = workspaceDefaults.int(for: "messageFontSizeAdjustment", default: 0) + selectedModel = workspaceDefaults.string(for: "selectedModel") ?? "opus" + selectedAgentProvider = AgentProvider(rawValue: workspaceDefaults.string(for: "selectedAgentProvider") ?? "") ?? .claudeCode + selectedACPClientId = workspaceDefaults.string(for: "selectedACPClientId") ?? "" + selectedEffort = workspaceDefaults.string(for: "selectedEffort") ?? "auto" + + var provider = SummarizationProvider(rawValue: workspaceDefaults.string(for: "summarizationProvider") ?? "") ?? .selectedClient + if provider == .appleFoundationModel, !FoundationModelSummarizationService.isAvailable { + provider = .selectedClient + } + summarizationProvider = provider + openAISummarizationEndpoint = workspaceDefaults.string(for: "openAISummarizationEndpoint") ?? AppState.defaultOpenAISummarizationEndpoint + openAISummarizationAPIKey = KeychainHelper.readString( + service: AppState.openAISummarizationKeychainService, + account: activeWorkspace.openAISummarizationKeychainAccount + ) ?? "" + openAISummarizationModel = workspaceDefaults.string(for: "openAISummarizationModel") ?? "" + + memoryEnabled = workspaceDefaults.bool(for: "memoryEnabled", default: true) + memoryAutoCreateEnabled = workspaceDefaults.bool(for: "memoryAutoCreateEnabled", default: true) + memoryInjectEnabled = workspaceDefaults.bool(for: "memoryInjectEnabled", default: true) + memoryRetrievalMode = MemoryRetrievalMode(rawValue: workspaceDefaults.string(for: "memoryRetrievalMode") ?? "") ?? .balanced + memoryMaxContextItems = workspaceDefaults.int(for: "memoryMaxContextItems", default: 5) + + notificationsEnabled = workspaceDefaults.bool(for: "notificationsEnabled", default: true) + enableAutoCIFix = workspaceDefaults.bool(for: "enableAutoCIFix", default: false) + focusMode = workspaceDefaults.bool(for: "focusMode", default: false) + autoArchiveEnabled = workspaceDefaults.bool(for: "autoArchiveEnabled", default: true) + archiveRetentionDays = workspaceDefaults.int(for: "archiveRetentionDays", default: AppState.defaultArchiveRetentionDays) + autoDeleteEnabled = workspaceDefaults.bool(for: "autoDeleteEnabled", default: false) + deleteRetentionDays = workspaceDefaults.int(for: "deleteRetentionDays", default: AppState.defaultDeleteRetentionDays) + + if let data = workspaceDefaults.data(for: AppState.autoPreviewSettingsKey), + let settings = try? JSONDecoder().decode(AttachmentAutoPreviewSettings.self, from: data) + { + autoPreviewSettings = settings + } else { + autoPreviewSettings = AttachmentAutoPreviewSettings() + } + + permissionMode = PermissionMode(rawValue: workspaceDefaults.string(for: "selectedPermissionMode") ?? "") ?? .default + onboardingCompleted = workspaceDefaults.bool(for: "onboardingCompleted", default: false) + wasOnboardedAtLaunch = onboardingCompleted + seenWhatsNewSlugs = Set(workspaceDefaults.stringArray(for: AppState.seenWhatsNewKey)) + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 5fbf355e..415751c7 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -183,6 +183,14 @@ final class AppState { // MARK: - Projects (shared) + /// Process-global services shared across all workspace windows. + let core: AppCore + var workspaceRegistry: WorkspaceRegistry { core.workspaceRegistry } + + var workspaces: [AppWorkspace] = [.personal] + var activeWorkspace: AppWorkspace = .personal + var workspaceDefaults = WorkspaceDefaults(workspaceID: AppWorkspace.personalID) + var projects: [Project] = [] // MARK: - Per-Session State (shared — managed independently by session ID regardless of window) @@ -252,9 +260,9 @@ final class AppState { // MARK: - Theme - var selectedTheme: AppTheme = .init(rawValue: UserDefaults.standard.string(forKey: "selectedTheme") ?? "") ?? .claude { + var selectedTheme: AppTheme = .claude { didSet { - UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme") + workspaceDefaults.set(selectedTheme.rawValue, for: "selectedTheme") ThemeStore.shared.current = selectedTheme themeRevision += 1 } @@ -265,9 +273,9 @@ final class AppState { // MARK: - Font Size - var fontSizeAdjustment: Int = (UserDefaults.standard.object(forKey: "fontSizeAdjustment") as? Int) ?? 0 { + var fontSizeAdjustment: Int = 0 { didSet { - UserDefaults.standard.set(fontSizeAdjustment, forKey: "fontSizeAdjustment") + workspaceDefaults.set(fontSizeAdjustment, for: "fontSizeAdjustment") ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment themeRevision += 1 } @@ -283,9 +291,9 @@ final class AppState { fontSizeAdjustment -= 1 } - var messageFontSizeAdjustment: Int = (UserDefaults.standard.object(forKey: "messageFontSizeAdjustment") as? Int) ?? 0 { + var messageFontSizeAdjustment: Int = 0 { didSet { - UserDefaults.standard.set(messageFontSizeAdjustment, forKey: "messageFontSizeAdjustment") + workspaceDefaults.set(messageFontSizeAdjustment, for: "messageFontSizeAdjustment") ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment themeRevision += 1 } @@ -302,12 +310,12 @@ final class AppState { } - var selectedModel: String = UserDefaults.standard.string(forKey: "selectedModel") ?? "opus" { - didSet { UserDefaults.standard.set(selectedModel, forKey: "selectedModel") } + var selectedModel: String = "opus" { + didSet { workspaceDefaults.set(selectedModel, for: "selectedModel") } } - var selectedAgentProvider: AgentProvider = .init(rawValue: UserDefaults.standard.string(forKey: "selectedAgentProvider") ?? "") ?? .claudeCode { - didSet { UserDefaults.standard.set(selectedAgentProvider.rawValue, forKey: "selectedAgentProvider") } + var selectedAgentProvider: AgentProvider = .claudeCode { + didSet { workspaceDefaults.set(selectedAgentProvider.rawValue, for: "selectedAgentProvider") } } var codexModels: [AgentModel] = [] @@ -321,47 +329,38 @@ final class AppState { var acpRegistryLoading: Bool = false /// Selected default ACP client id (when `selectedAgentProvider == .acp`). - var selectedACPClientId: String = UserDefaults.standard.string(forKey: "selectedACPClientId") ?? "" { - didSet { UserDefaults.standard.set(selectedACPClientId, forKey: "selectedACPClientId") } + var selectedACPClientId: String = "" { + didSet { workspaceDefaults.set(selectedACPClientId, for: "selectedACPClientId") } } - var selectedEffort: String = UserDefaults.standard.string(forKey: "selectedEffort") ?? "auto" { - didSet { UserDefaults.standard.set(selectedEffort, forKey: "selectedEffort") } + var selectedEffort: String = "auto" { + didSet { workspaceDefaults.set(selectedEffort, for: "selectedEffort") } } // MARK: - Summarization - var summarizationProvider: SummarizationProvider = { - let stored = SummarizationProvider(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient - if stored == .appleFoundationModel, !FoundationModelSummarizationService.isAvailable { - return .selectedClient - } - return stored - }() { - didSet { UserDefaults.standard.set(summarizationProvider.rawValue, forKey: "summarizationProvider") } + var summarizationProvider: SummarizationProvider = .selectedClient { + didSet { workspaceDefaults.set(summarizationProvider.rawValue, for: "summarizationProvider") } } - var openAISummarizationEndpoint: String = UserDefaults.standard.string(forKey: "openAISummarizationEndpoint") ?? AppState.defaultOpenAISummarizationEndpoint { - didSet { UserDefaults.standard.set(openAISummarizationEndpoint, forKey: "openAISummarizationEndpoint") } + var openAISummarizationEndpoint: String = AppState.defaultOpenAISummarizationEndpoint { + didSet { workspaceDefaults.set(openAISummarizationEndpoint, for: "openAISummarizationEndpoint") } } - var openAISummarizationAPIKey: String = KeychainHelper.readString( - service: AppState.openAISummarizationKeychainService, - account: AppState.openAISummarizationKeychainAccount - ) ?? "" { + var openAISummarizationAPIKey: String = "" { didSet { let trimmed = openAISummarizationAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) do { if trimmed.isEmpty { try KeychainHelper.delete( service: AppState.openAISummarizationKeychainService, - account: AppState.openAISummarizationKeychainAccount + account: activeWorkspace.openAISummarizationKeychainAccount ) } else if let data = trimmed.data(using: .utf8) { try KeychainHelper.save( data, service: AppState.openAISummarizationKeychainService, - account: AppState.openAISummarizationKeychainAccount + account: activeWorkspace.openAISummarizationKeychainAccount ) } } catch { @@ -370,8 +369,8 @@ final class AppState { } } - var openAISummarizationModel: String = UserDefaults.standard.string(forKey: "openAISummarizationModel") ?? "" { - didSet { UserDefaults.standard.set(openAISummarizationModel, forKey: "openAISummarizationModel") } + var openAISummarizationModel: String = "" { + didSet { workspaceDefaults.set(openAISummarizationModel, for: "openAISummarizationModel") } } var openAISummarizationModels: [String] = [] @@ -382,36 +381,34 @@ final class AppState { // MARK: - Memory - var memoryEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryEnabled") as? Bool) ?? true { - didSet { UserDefaults.standard.set(memoryEnabled, forKey: "memoryEnabled") } + var memoryEnabled: Bool = true { + didSet { workspaceDefaults.set(memoryEnabled, for: "memoryEnabled") } } - var memoryAutoCreateEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryAutoCreateEnabled") as? Bool) ?? true { - didSet { UserDefaults.standard.set(memoryAutoCreateEnabled, forKey: "memoryAutoCreateEnabled") } + var memoryAutoCreateEnabled: Bool = true { + didSet { workspaceDefaults.set(memoryAutoCreateEnabled, for: "memoryAutoCreateEnabled") } } - var memoryInjectEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryInjectEnabled") as? Bool) ?? true { - didSet { UserDefaults.standard.set(memoryInjectEnabled, forKey: "memoryInjectEnabled") } + var memoryInjectEnabled: Bool = true { + didSet { workspaceDefaults.set(memoryInjectEnabled, for: "memoryInjectEnabled") } } - var memoryRetrievalMode: MemoryRetrievalMode = { - MemoryRetrievalMode(rawValue: UserDefaults.standard.string(forKey: "memoryRetrievalMode") ?? "") ?? .balanced - }() { - didSet { UserDefaults.standard.set(memoryRetrievalMode.rawValue, forKey: "memoryRetrievalMode") } + var memoryRetrievalMode: MemoryRetrievalMode = .balanced { + didSet { workspaceDefaults.set(memoryRetrievalMode.rawValue, for: "memoryRetrievalMode") } } var memoryInjectionScoreThreshold: Float { memoryRetrievalMode.scoreThreshold } - var memoryMaxContextItems: Int = (UserDefaults.standard.object(forKey: "memoryMaxContextItems") as? Int) ?? 5 { + var memoryMaxContextItems: Int = 5 { didSet { let clamped = max(1, min(12, memoryMaxContextItems)) if clamped != memoryMaxContextItems { memoryMaxContextItems = clamped return } - UserDefaults.standard.set(memoryMaxContextItems, forKey: "memoryMaxContextItems") + workspaceDefaults.set(memoryMaxContextItems, for: "memoryMaxContextItems") } } @@ -419,8 +416,8 @@ final class AppState { // MARK: - Notifications - var notificationsEnabled: Bool = (UserDefaults.standard.object(forKey: "notificationsEnabled") as? Bool) ?? true { - didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") } + var notificationsEnabled: Bool = true { + didSet { workspaceDefaults.set(notificationsEnabled, for: "notificationsEnabled") } } // MARK: - GitHub Actions CI @@ -430,8 +427,8 @@ final class AppState { /// it on the user's behalf. The failure notification fires regardless of this /// toggle — only the auto-fix behavior is gated. Off by default (spends tokens /// unprompted). - var enableAutoCIFix: Bool = (UserDefaults.standard.object(forKey: "enableAutoCIFix") as? Bool) ?? false { - didSet { UserDefaults.standard.set(enableAutoCIFix, forKey: "enableAutoCIFix") } + var enableAutoCIFix: Bool = false { + didSet { workspaceDefaults.set(enableAutoCIFix, for: "enableAutoCIFix") } } /// Per-project working-tree dirty flag (`git status` non-empty), refreshed by @@ -485,8 +482,8 @@ final class AppState { // MARK: - Focus Mode - var focusMode: Bool = (UserDefaults.standard.object(forKey: "focusMode") as? Bool) ?? false { - didSet { UserDefaults.standard.set(focusMode, forKey: "focusMode") } + var focusMode: Bool = false { + didSet { workspaceDefaults.set(focusMode, for: "focusMode") } } // MARK: - Archive @@ -496,18 +493,18 @@ final class AppState { /// chats alone — the toggle is authoritative. static let defaultArchiveRetentionDays = 7 - var autoArchiveEnabled: Bool = (UserDefaults.standard.object(forKey: "autoArchiveEnabled") as? Bool) ?? true { - didSet { UserDefaults.standard.set(autoArchiveEnabled, forKey: "autoArchiveEnabled") } + var autoArchiveEnabled: Bool = true { + didSet { workspaceDefaults.set(autoArchiveEnabled, for: "autoArchiveEnabled") } } - var archiveRetentionDays: Int = (UserDefaults.standard.object(forKey: "archiveRetentionDays") as? Int) ?? AppState.defaultArchiveRetentionDays { + var archiveRetentionDays: Int = AppState.defaultArchiveRetentionDays { didSet { let clamped = max(1, min(365, archiveRetentionDays)) if clamped != archiveRetentionDays { archiveRetentionDays = clamped return } - UserDefaults.standard.set(archiveRetentionDays, forKey: "archiveRetentionDays") + workspaceDefaults.set(archiveRetentionDays, for: "archiveRetentionDays") } } @@ -515,18 +512,18 @@ final class AppState { /// Pinned chats are never auto-deleted. Disabled by default — destructive. static let defaultDeleteRetentionDays = 30 - var autoDeleteEnabled: Bool = (UserDefaults.standard.object(forKey: "autoDeleteEnabled") as? Bool) ?? false { - didSet { UserDefaults.standard.set(autoDeleteEnabled, forKey: "autoDeleteEnabled") } + var autoDeleteEnabled: Bool = false { + didSet { workspaceDefaults.set(autoDeleteEnabled, for: "autoDeleteEnabled") } } - var deleteRetentionDays: Int = (UserDefaults.standard.object(forKey: "deleteRetentionDays") as? Int) ?? AppState.defaultDeleteRetentionDays { + var deleteRetentionDays: Int = AppState.defaultDeleteRetentionDays { didSet { let clamped = max(1, min(365, deleteRetentionDays)) if clamped != deleteRetentionDays { deleteRetentionDays = clamped return } - UserDefaults.standard.set(deleteRetentionDays, forKey: "deleteRetentionDays") + workspaceDefaults.set(deleteRetentionDays, for: "deleteRetentionDays") } } @@ -535,16 +532,14 @@ final class AppState { static let autoPreviewSettingsKey = "attachmentAutoPreviewSettings" var autoPreviewSettings: AttachmentAutoPreviewSettings = { - guard let data = UserDefaults.standard.data(forKey: AppState.autoPreviewSettingsKey), + guard let data = WorkspaceDefaults(workspaceID: AppWorkspace.personalID).data(for: AppState.autoPreviewSettingsKey), let settings = try? JSONDecoder().decode(AttachmentAutoPreviewSettings.self, from: data) - else { - return AttachmentAutoPreviewSettings() - } + else { return AttachmentAutoPreviewSettings() } return settings }() { didSet { if let data = try? JSONEncoder().encode(autoPreviewSettings) { - UserDefaults.standard.set(data, forKey: AppState.autoPreviewSettingsKey) + workspaceDefaults.set(data, for: AppState.autoPreviewSettingsKey) } } } @@ -573,84 +568,6 @@ final class AppState { sessionStates.values.reduce(0) { $0 + ($1.hasUncheckedCompletion ? 1 : 0) } } - func cachedRateLimitUsage(for provider: AgentProvider) -> RateLimitUsage? { - switch provider { - case .claudeCode: - return latestRateLimitUsage - case .codex: - return latestCodexRateLimitUsage - case .acp: - return nil - } - } - - func rateLimitUsage(for provider: AgentProvider, forceRefresh: Bool = false) async -> RateLimitUsage? { - if !forceRefresh, let cached = cachedRateLimitUsage(for: provider) { - return cached - } - - if let task = rateLimitUsageRefreshTasks[provider] { - return await task.value ?? cachedRateLimitUsage(for: provider) - } - - let task = Task { [weak self] in - guard let self else { return nil } - switch provider { - case .claudeCode: - return await RateLimitService.shared.fetchUsage(forceRefresh: forceRefresh) - case .codex: - guard self.codexInstalled else { return nil } - return await self.codex.fetchRateLimits(forceRefresh: forceRefresh) - case .acp: - return nil - } - } - rateLimitUsageRefreshTasks[provider] = task - - let usage = await task.value - rateLimitUsageRefreshTasks[provider] = nil - - if let usage { - storeRateLimitUsage(usage, for: provider) - return usage - } - return cachedRateLimitUsage(for: provider) - } - - func storeRateLimitUsage(_ usage: RateLimitUsage, for provider: AgentProvider) { - switch provider { - case .claudeCode: - latestRateLimitUsage = usage - case .codex: - latestCodexRateLimitUsage = usage - case .acp: - break - } - // Refresh the mobile home-screen widget's usage figures. - MobileSyncService.shared.pushWidgetUpdate() - } - - /// Force-refresh the shared Claude rate-limit usage. - func refreshRateLimitUsage(forceRefresh: Bool = false) async { - _ = await rateLimitUsage(for: .claudeCode, forceRefresh: forceRefresh) - } - - /// Warm Codex usage early so the status bar can render Codex limits from cache. - func refreshCodexRateLimitUsage(forceRefresh: Bool = false) async { - _ = await rateLimitUsage(for: .codex, forceRefresh: forceRefresh) - } - - func refreshSelectedAgentRateLimitUsage(forceRefresh: Bool = false) async { - switch selectedAgentProvider { - case .claudeCode: - await refreshRateLimitUsage(forceRefresh: forceRefresh) - case .codex: - await refreshCodexRateLimitUsage(forceRefresh: forceRefresh) - case .acp: - break - } - } - func setDefaultAgentProvider(_ provider: AgentProvider) { guard provider != selectedAgentProvider else { return } @@ -869,7 +786,7 @@ final class AppState { // MARK: - Permissions var permissionMode: PermissionMode = .default { - didSet { UserDefaults.standard.set(permissionMode.rawValue, forKey: "selectedPermissionMode") } + didSet { workspaceDefaults.set(permissionMode.rawValue, for: "selectedPermissionMode") } } // MARK: - rxauth + autopilot @@ -929,20 +846,19 @@ final class AppState { var claudeInstalled = false var codexInstalled = false - var onboardingCompleted = UserDefaults.standard.bool(forKey: "onboardingCompleted") + var onboardingCompleted = false // MARK: - What's New - private static let seenWhatsNewKey = "seenWhatsNewSlugs" + static let seenWhatsNewKey = "seenWhatsNewSlugs" /// Whether onboarding had already been completed when this launch started. /// Distinguishes an existing user (who should see "What's New" for newly /// added features) from a brand-new install (for whom nothing is "new"). - let wasOnboardedAtLaunch = UserDefaults.standard.bool(forKey: "onboardingCompleted") + var wasOnboardedAtLaunch = false /// Slugs of "What's New" feature cards the user has already seen. - private(set) var seenWhatsNewSlugs: Set = - Set(UserDefaults.standard.stringArray(forKey: AppState.seenWhatsNewKey) ?? []) + var seenWhatsNewSlugs: Set = [] /// Controls presentation of the What's New sheet. var showWhatsNewSheet = false @@ -967,7 +883,7 @@ final class AppState { changed = true } guard changed else { return } - UserDefaults.standard.set(Array(seenWhatsNewSlugs), forKey: Self.seenWhatsNewKey) + workspaceDefaults.set(Array(seenWhatsNewSlugs), for: Self.seenWhatsNewKey) } /// Marks every shipped feature as seen. Used for brand-new installs so the @@ -1038,22 +954,22 @@ final class AppState { // MARK: - Services - let rxAuth = RxAuthService.shared - let autopilot: AutopilotService - let secrets: SecretsService - let ciUpdates: CIUpdateService + var rxAuth: RxAuthService + var autopilot: AutopilotService + var secrets: SecretsService + var ciUpdates: CIUpdateService /// Talks to github-pm's docs API (search, repos, documents, upload tokens). - let docs: DocsService + var docs: DocsService /// Talks to github-pm's release API (repos, workflows, dispatch, secret). - let release: ReleaseService + var release: ReleaseService /// Passkey-derived KEK cache for the secrets feature (macOS only). let secretsKeyVault = SecretsKeyVault() /// Cached enrollment status for the secrets feature: `nil` = unknown. var secretsEnrolled: Bool? let permission = PermissionServer() - let metaStore = SessionMetaStore() - let cliStore: CLISessionStore - let claude: ClaudeService + var metaStore: SessionMetaStore { core.metaStore } + var cliStore: CLISessionStore { core.cliStore } + var claude: ClaudeService { core.claude } let codex: CodexAppServer let acp: ACPService @@ -1065,12 +981,13 @@ final class AppState { let acpRegistryService = ACPRegistryService() let openAISummarization = OpenAISummarizationService() let foundationModelSummarization = FoundationModelSummarizationService() - let persistence: any AppStatePersistenceService - let marketplace = MarketplaceService() - let mcp: MCPService - let threadStore: ThreadStore - let searchService = ThreadSearchService() - let memoryService = MemoryService() + var persistence: any AppStatePersistenceService + var marketplace: MarketplaceService + var marketplaceStateRevision = 0 + var mcp: MCPService + var threadStore: ThreadStore + var searchService = ThreadSearchService() + var memoryService = MemoryService() /// Live progress for a user-triggered full reindex. `nil` when idle. var reindexProgress: (done: Int, total: Int)? = nil let runService = RunService() @@ -1189,6 +1106,7 @@ final class AppState { var mcpIsLoading: Bool = false var mcpListError: String? var mcpPeriodicProbeTask: Task? + var mcpStateRevision = 0 /// 5 minutes — balances disconnect-detection latency against probe cost /// (each tick spawns one stdio subprocess per stdio server). static let mcpPeriodicProbeInterval: UInt64 = 300 * 1_000_000_000 @@ -1197,26 +1115,52 @@ final class AppState { /// in the (window-less) Settings sheet. var activeProjectPath: String? + /// Convenience init that creates a fresh, unshared `AppCore`. Used by the + /// menu-bar / tests / any call site that doesn't yet thread a shared core. + convenience init( + persistence injectedPersistence: (any AppStatePersistenceService)? = nil, + startBackgroundServices: Bool = true + ) { + self.init( + core: AppCore(), + persistence: injectedPersistence, + startBackgroundServices: startBackgroundServices + ) + } + init( + core: AppCore, + workspace: AppWorkspace? = nil, persistence injectedPersistence: (any AppStatePersistenceService)? = nil, startBackgroundServices: Bool = true ) { - let metaStore = self.metaStore - let cliStore = CLISessionStore(metaStore: metaStore) - self.cliStore = cliStore - let claude = ClaudeService(cliStore: cliStore) - self.claude = claude + self.core = core + let workspaceSnapshot = core.workspaceRegistry.load() + // Bind this AppState to a specific workspace when one is supplied + // (multi-window); otherwise fall back to the registry's active workspace. + let active = workspace ?? workspaceSnapshot.active + self.activeWorkspace = active + self.workspaces = workspaceSnapshot.all + self.workspaceDefaults = WorkspaceDefaults(workspaceID: active.id) + + let metaStore = core.metaStore + let cliStore = core.cliStore + let claude = core.claude self.codex = CodexAppServer() let acp = ACPService() self.acp = acp - self.persistence = injectedPersistence ?? PersistenceService(metaStore: metaStore, cliStore: cliStore) - self.mcp = MCPService(claudeService: claude) - self.threadStore = ThreadStore.make() - self.autopilot = AutopilotService(rxAuth: RxAuthService.shared) - self.secrets = SecretsService(rxAuth: RxAuthService.shared) - self.ciUpdates = CIUpdateService(rxAuth: RxAuthService.shared) - self.docs = DocsService(rxAuth: RxAuthService.shared) - self.release = ReleaseService(rxAuth: RxAuthService.shared) + self.persistence = injectedPersistence ?? PersistenceService(metaStore: metaStore, cliStore: cliStore, baseURL: active.storageURL) + self.marketplace = MarketplaceService(baseURL: active.storageURL) + self.mcp = MCPService(baseURL: active.storageURL, claudeService: claude) + self.threadStore = ThreadStore.make(baseURL: active.storageURL) + let rxAuth = RxAuthService(keychainService: active.rxAuthKeychainService) + self.rxAuth = rxAuth + self.autopilot = AutopilotService(rxAuth: rxAuth) + self.secrets = SecretsService(rxAuth: rxAuth) + self.ciUpdates = CIUpdateService(rxAuth: rxAuth) + self.docs = DocsService(rxAuth: rxAuth) + self.release = ReleaseService(rxAuth: rxAuth) + loadWorkspaceSettings() self.runService.onTasksChanged = { [weak self] in Task { @MainActor [weak self] in self?.broadcastMobileRunTasks() @@ -1242,7 +1186,9 @@ final class AppState { let memoryService = self.memoryService let threadStore = self.threadStore let persistence = self.persistence + let workspaceDefaults = self.workspaceDefaults Task.detached(priority: .utility) { [weak self] in + await searchService.setWorkspaceDefaults(workspaceDefaults) await searchService.start(threadStore: threadStore) await memoryService.start(threadStore: threadStore) await searchService.backfillIfNeeded( @@ -1253,9 +1199,10 @@ final class AppState { } ) } - - setupMobileSyncBridge() } + // Mobile sync is owned by a single (frontmost) workspace — bound by + // WorkspaceManager, not here, so multiple workspace windows don't all + // respond to the same mobile request. // Build the hook controller/manager last — the controller captures // `self` (weakly) and every other service it forwards to is now ready. diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 72b5047e..2d47a5f8 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -16,11 +16,24 @@ extension FocusedValues { } } +// MARK: - WorkspaceWindowValue + +/// Identifies which workspace a main window is bound to. The primary +/// `WindowGroup` is keyed by this value so each workspace gets its own window +/// (and its own `AppState`), and reopening the same workspace refocuses its +/// existing window rather than spawning a duplicate. +struct WorkspaceWindowValue: Codable, Hashable { + let workspaceID: String +} + // MARK: - ProjectWindowValue struct ProjectWindowValue: Codable, Hashable { let projectId: UUID let instanceId: UUID + /// Workspace that owns this project, so a detached project window resolves + /// the correct per-workspace `AppState`. + var workspaceID: String? } // MARK: - TerminalWindowValue @@ -33,7 +46,7 @@ struct TerminalWindowValue: Codable, Hashable { @main struct RxCodeApp: App { - @State private var appState = AppState() + @State private var workspaceManager = WorkspaceManager() @FocusedValue(\.startNewChat) private var startNewChat @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true private let updateService = UpdateService.shared @@ -46,10 +59,19 @@ struct RxCodeApp: App { ]) } + /// AppState for the frontmost workspace window. Global scenes (Settings, + /// menu bar, the Theme command) act on whichever workspace is currently key. + private var appState: AppState { workspaceManager.frontmostAppState } + var body: some Scene { - WindowGroup { - MainWindowRoot(appState: appState) - .focusable(false) + WindowGroup(id: "workspace-window", for: WorkspaceWindowValue.self) { $value in + MainWindowRoot( + workspaceManager: workspaceManager, + workspaceID: value.workspaceID + ) + .focusable(false) + } defaultValue: { + WorkspaceWindowValue(workspaceID: workspaceManager.frontmostWorkspaceID) } .defaultSize(width: 1000, height: 700) .defaultLaunchBehavior(.presented) @@ -75,13 +97,18 @@ struct RxCodeApp: App { .disabled(appState.selectedTheme == theme) } } + AutomationCommands() } // Dedicated project window — opened on double-click WindowGroup(id: "project-window", for: ProjectWindowValue.self) { $value in if let id = value?.projectId { - ProjectWindowRoot(appState: appState, projectId: id) - .focusable(false) + ProjectWindowRoot( + workspaceManager: workspaceManager, + workspaceID: value?.workspaceID ?? workspaceManager.frontmostWorkspaceID, + projectId: id + ) + .focusable(false) } } .defaultSize(width: 1000, height: 700) @@ -96,9 +123,26 @@ struct RxCodeApp: App { SettingsWindowRoot(appState: appState) } + // Standalone Automation windows, opened from the "Automation" menu. + Window("Autopilot", id: "autopilot-window") { + AutopilotWindowRoot(appState: appState) + } + .defaultSize(width: 720, height: 640) + + Window("Hooks", id: "hooks-window") { + HooksWindowRoot(appState: appState) + } + .defaultSize(width: 760, height: 620) + + Window("Custom Context Menus", id: "custom-menus-window") { + CustomMenusWindowRoot(appState: appState) + } + .defaultSize(width: 720, height: 620) + MenuBarExtra(isInserted: $showMenuBarExtra) { MenuBarContentView() .environment(appState) + .environment(workspaceManager) } label: { MenuBarLabel() .environment(appState) @@ -222,6 +266,8 @@ private struct MenuBarLabelContent: View { private struct MenuBarContentView: View { @Environment(AppState.self) private var appState @State private var isRefreshing = false + @State private var showCreateWorkspaceSheet = false + @State private var showManageWorkspaceSheet = false private var selectedUsage: RateLimitUsage? { switch appState.selectedAgentProvider { @@ -257,6 +303,10 @@ private struct MenuBarContentView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { + WorkspaceSwitcher( + showingCreateSheet: $showCreateWorkspaceSheet, + showingManageSheet: $showManageWorkspaceSheet + ) header agentPicker @@ -291,6 +341,14 @@ private struct MenuBarContentView: View { } .padding(14) .frame(width: 280) + .sheet(isPresented: $showCreateWorkspaceSheet) { + CreateWorkspaceSheet() + .environment(appState) + } + .sheet(isPresented: $showManageWorkspaceSheet) { + ManageWorkspacesSheet() + .environment(appState) + } .task { await appState.refreshSelectedAgentRateLimitUsage() } @@ -592,15 +650,20 @@ private struct MenuBarUsageBar: View { // MARK: - Main Window Root struct MainWindowRoot: View { - let appState: AppState + let workspaceManager: WorkspaceManager + let workspaceID: String + @Environment(\.controlActiveState) private var controlActiveState @State private var windowState = WindowState() @State private var chatBridge = ChatBridge() + private var appState: AppState { workspaceManager.appState(for: workspaceID) } + var body: some View { ZStack { if appState.isInitialized { MainView() .environment(appState) + .environment(workspaceManager) .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in @@ -640,6 +703,10 @@ struct MainWindowRoot: View { } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) + .onAppear { workspaceManager.markFrontmost(workspaceID) } + .onChange(of: controlActiveState) { _, state in + if state == .key { workspaceManager.markFrontmost(workspaceID) } + } .task { await appState.initialize() appState.setupChatBridge(chatBridge, for: windowState) @@ -648,7 +715,6 @@ struct MainWindowRoot: View { NotificationService.shared.onNotificationTapped = { projectId, sessionId in appState.handleNotificationTap(projectId: projectId, sessionId: sessionId, mainWindow: windowState) } - MobileSyncService.shared.start() } } } @@ -685,19 +751,80 @@ struct SettingsWindowRoot: View { } } +// MARK: - Automation Commands + +/// "Automation" menu in the top menu bar, opening the Autopilot, Hooks, and +/// Custom Context Menu management UIs as standalone windows. +struct AutomationCommands: Commands { + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandMenu("Automation") { + Button("Autopilot") { openWindow(id: "autopilot-window") } + Button("Hooks") { openWindow(id: "hooks-window") } + Button("Custom Context Menus") { openWindow(id: "custom-menus-window") } + } + } +} + +// MARK: - Automation Window Roots + +struct AutopilotWindowRoot: View { + let appState: AppState + + var body: some View { + AutopilotSettingsTab() + .environment(appState) + .frame(minWidth: 560, minHeight: 480) + } +} + +struct HooksWindowRoot: View { + let appState: AppState + + var body: some View { + ScrollView { + HooksSettingsSection() + .environment(appState) + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minWidth: 560, minHeight: 360) + } +} + +struct CustomMenusWindowRoot: View { + let appState: AppState + + var body: some View { + ScrollView { + CustomMenusSettingsSection() + .environment(appState) + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minWidth: 560, minHeight: 420) + } +} + // MARK: - Project Window Root struct ProjectWindowRoot: View { - let appState: AppState + let workspaceManager: WorkspaceManager + let workspaceID: String let projectId: UUID + @Environment(\.controlActiveState) private var controlActiveState @State private var windowState = WindowState() @State private var chatBridge = ChatBridge() + private var appState: AppState { workspaceManager.appState(for: workspaceID) } + var body: some View { ZStack { if appState.isInitialized { MainView() .environment(appState) + .environment(workspaceManager) .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in @@ -728,8 +855,12 @@ struct ProjectWindowRoot: View { .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) .onAppear { windowState.isProjectWindow = true + workspaceManager.markFrontmost(workspaceID) appState.registerOpenProjectWindow(projectId) } + .onChange(of: controlActiveState) { _, state in + if state == .key { workspaceManager.markFrontmost(workspaceID) } + } .onDisappear { appState.unregisterOpenProjectWindow(projectId) } .task { // Wait for the main window's AppState.initialize() to finish before diff --git a/RxCode/App/Workspace.swift b/RxCode/App/Workspace.swift new file mode 100644 index 00000000..3566f34f --- /dev/null +++ b/RxCode/App/Workspace.swift @@ -0,0 +1,211 @@ +import Foundation +import RxCodeCore +import os + +struct AppWorkspace: Codable, Identifiable, Hashable, Sendable { + nonisolated static let personalID = AppSupport.personalWorkspaceID + + let id: String + var name: String + var createdAt: Date + var updatedAt: Date + + nonisolated static var personal: AppWorkspace { + AppWorkspace(id: personalID, name: "Personal", createdAt: .distantPast, updatedAt: .distantPast) + } + + nonisolated var isPersonal: Bool { id == Self.personalID } + nonisolated var storageURL: URL { AppSupport.workspaceScopedURL(id: id) } + nonisolated var rxAuthKeychainService: String { + isPersonal ? RxAuthService.defaultKeychainService : "\(RxAuthService.defaultKeychainService).workspace.\(id)" + } + nonisolated var openAISummarizationKeychainAccount: String { + isPersonal ? AppState.openAISummarizationKeychainAccount : "\(AppState.openAISummarizationKeychainAccount).\(id)" + } +} + +final class WorkspaceRegistry { + private struct Snapshot: Codable { + var activeWorkspaceID: String + var workspaces: [AppWorkspace] + } + + private let url: URL + private let logger = Logger(subsystem: "com.claudework", category: "WorkspaceRegistry") + + init(url: URL = AppSupport.bundleScopedURL.appendingPathComponent("workspaces.json")) { + self.url = url + } + + func load() -> (active: AppWorkspace, all: [AppWorkspace]) { + var snapshot = readSnapshot() ?? Snapshot(activeWorkspaceID: AppWorkspace.personalID, workspaces: [.personal]) + normalize(&snapshot) + persist(snapshot) + let active = snapshot.workspaces.first { $0.id == snapshot.activeWorkspaceID } ?? snapshot.workspaces[0] + return (active, snapshot.workspaces) + } + + func createWorkspace(name: String) -> (active: AppWorkspace, all: [AppWorkspace]) { + var snapshot = readSnapshot() ?? Snapshot(activeWorkspaceID: AppWorkspace.personalID, workspaces: [.personal]) + normalize(&snapshot) + let now = Date() + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let baseName = trimmed.isEmpty ? "Workspace" : trimmed + let workspace = AppWorkspace(id: UUID().uuidString.lowercased(), name: uniqueName(baseName, in: snapshot.workspaces), createdAt: now, updatedAt: now) + snapshot.workspaces.append(workspace) + snapshot.activeWorkspaceID = workspace.id + persist(snapshot) + return (workspace, snapshot.workspaces) + } + + func switchWorkspace(id: String) -> (active: AppWorkspace, all: [AppWorkspace])? { + var snapshot = readSnapshot() ?? Snapshot(activeWorkspaceID: AppWorkspace.personalID, workspaces: [.personal]) + normalize(&snapshot) + guard let active = snapshot.workspaces.first(where: { $0.id == id }) else { return nil } + snapshot.activeWorkspaceID = active.id + persist(snapshot) + return (active, snapshot.workspaces) + } + + func renameWorkspace(id: String, name: String) -> (active: AppWorkspace, all: [AppWorkspace])? { + guard id != AppWorkspace.personalID else { return nil } + var snapshot = readSnapshot() ?? Snapshot(activeWorkspaceID: AppWorkspace.personalID, workspaces: [.personal]) + normalize(&snapshot) + guard let index = snapshot.workspaces.firstIndex(where: { $0.id == id }) else { return nil } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + var others = snapshot.workspaces + others.remove(at: index) + snapshot.workspaces[index].name = uniqueName(trimmed, in: others) + snapshot.workspaces[index].updatedAt = Date() + persist(snapshot) + let active = snapshot.workspaces.first { $0.id == snapshot.activeWorkspaceID } ?? snapshot.workspaces[0] + return (active, snapshot.workspaces) + } + + func deleteWorkspace(id: String) -> (active: AppWorkspace, all: [AppWorkspace])? { + guard id != AppWorkspace.personalID else { return nil } + var snapshot = readSnapshot() ?? Snapshot(activeWorkspaceID: AppWorkspace.personalID, workspaces: [.personal]) + normalize(&snapshot) + guard snapshot.workspaces.contains(where: { $0.id == id }) else { return nil } + snapshot.workspaces.removeAll { $0.id == id } + if snapshot.activeWorkspaceID == id { + snapshot.activeWorkspaceID = AppWorkspace.personalID + } + persist(snapshot) + let active = snapshot.workspaces.first { $0.id == snapshot.activeWorkspaceID } ?? snapshot.workspaces[0] + return (active, snapshot.workspaces) + } + + private func normalize(_ snapshot: inout Snapshot) { + if !snapshot.workspaces.contains(where: { $0.id == AppWorkspace.personalID }) { + snapshot.workspaces.insert(.personal, at: 0) + } + if !snapshot.workspaces.contains(where: { $0.id == snapshot.activeWorkspaceID }) { + snapshot.activeWorkspaceID = AppWorkspace.personalID + } + snapshot.workspaces.sort { + if $0.id == AppWorkspace.personalID { return true } + if $1.id == AppWorkspace.personalID { return false } + return $0.createdAt < $1.createdAt + } + } + + private func readSnapshot() -> Snapshot? { + guard let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + return try decoder.decode(Snapshot.self, from: data) + } catch { + logger.error("Failed to decode workspaces: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func persist(_ snapshot: Snapshot) { + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: url, options: .atomic) + } catch { + logger.error("Failed to save workspaces: \(error.localizedDescription, privacy: .public)") + } + } + + private func uniqueName(_ name: String, in workspaces: [AppWorkspace]) -> String { + let existing = Set(workspaces.map { $0.name.lowercased() }) + guard existing.contains(name.lowercased()) else { return name } + var index = 2 + while existing.contains("\(name) \(index)".lowercased()) { + index += 1 + } + return "\(name) \(index)" + } +} + +struct WorkspaceDefaults: Sendable { + let workspaceID: String + nonisolated var isPersonal: Bool { workspaceID == AppSupport.personalWorkspaceID } + + nonisolated func key(_ raw: String) -> String { + isPersonal ? raw : "workspace.\(workspaceID).\(raw)" + } + + nonisolated func string(for raw: String) -> String? { + UserDefaults.standard.string(forKey: key(raw)) + } + + nonisolated func set(_ value: String?, for raw: String) { + let key = key(raw) + if let value { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + + nonisolated func bool(for raw: String, default defaultValue: Bool) -> Bool { + let key = key(raw) + if UserDefaults.standard.object(forKey: key) == nil { return defaultValue } + return UserDefaults.standard.bool(forKey: key) + } + + nonisolated func set(_ value: Bool, for raw: String) { + UserDefaults.standard.set(value, forKey: key(raw)) + } + + nonisolated func int(for raw: String, default defaultValue: Int) -> Int { + let key = key(raw) + guard let value = UserDefaults.standard.object(forKey: key) as? Int else { return defaultValue } + return value + } + + nonisolated func set(_ value: Int, for raw: String) { + UserDefaults.standard.set(value, forKey: key(raw)) + } + + nonisolated func data(for raw: String) -> Data? { + UserDefaults.standard.data(forKey: key(raw)) + } + + nonisolated func set(_ value: Data?, for raw: String) { + let key = key(raw) + if let value { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + + nonisolated func stringArray(for raw: String) -> [String] { + UserDefaults.standard.stringArray(forKey: key(raw)) ?? [] + } + + nonisolated func set(_ value: [String], for raw: String) { + UserDefaults.standard.set(value, forKey: key(raw)) + } +} diff --git a/RxCode/App/WorkspaceManager.swift b/RxCode/App/WorkspaceManager.swift new file mode 100644 index 00000000..6406e5f6 --- /dev/null +++ b/RxCode/App/WorkspaceManager.swift @@ -0,0 +1,97 @@ +import Foundation +import RxCodeCore + +/// Owns the process-global `AppCore` and vends one `AppState` per workspace. +/// +/// Each open workspace window binds to the `AppState` returned by +/// `appState(for:)`; instances are cached for the app's lifetime so reopening a +/// workspace window reuses its state (and any in-flight chats keep running). +/// +/// Global surfaces that aren't tied to a single window — the menu bar, Settings, +/// mobile sync — follow `frontmostAppState`, i.e. whichever workspace window is +/// currently key. +@Observable +@MainActor +final class WorkspaceManager { + let core: AppCore + + /// One AppState per workspace id, created lazily on first access. + private var appStatesByWorkspaceID: [String: AppState] = [:] + + /// Workspace id of the key/frontmost window. Global surfaces follow this. + var frontmostWorkspaceID: String + + /// Workspace that currently owns mobile sync (the inbound mobile-request + /// observers + resolvers). Follows the frontmost window. + private var mobileSyncOwnerID: String? + /// Whether `MobileSyncService.start()` has run (it is process-global and not + /// idempotent, so it must fire exactly once). + private var didStartMobileSync = false + + init() { + let core = AppCore() + self.core = core + self.frontmostWorkspaceID = core.workspaceRegistry.load().active.id + } + + /// Known workspaces — source of truth is the shared registry. + var workspaces: [AppWorkspace] { core.workspaceRegistry.load().all } + + /// Returns the AppState bound to `workspaceID`, creating it on first use. + /// Falls back to the registry's active workspace when the id is unknown. + func appState(for workspaceID: String) -> AppState { + if let existing = appStatesByWorkspaceID[workspaceID] { + return existing + } + let snapshot = core.workspaceRegistry.load() + let workspace = snapshot.all.first { $0.id == workspaceID } ?? snapshot.active + let appState = AppState(core: core, workspace: workspace) + appStatesByWorkspaceID[workspace.id] = appState + return appState + } + + /// AppState for the frontmost workspace window — used by global surfaces. + var frontmostAppState: AppState { appState(for: frontmostWorkspaceID) } + + /// Whether an AppState has already been created for this workspace. + func hasAppState(for workspaceID: String) -> Bool { + appStatesByWorkspaceID[workspaceID] != nil + } + + /// Mark a workspace window as frontmost (called from window focus tracking). + /// Moves mobile-sync ownership to the new frontmost workspace. + func markFrontmost(_ workspaceID: String) { + frontmostWorkspaceID = workspaceID + transferMobileSyncOwnership(to: workspaceID) + } + + /// Hand mobile-sync ownership to `workspaceID`, tearing it down on the + /// previous owner. Starts the (non-idempotent) mobile service once. + private func transferMobileSyncOwnership(to workspaceID: String) { + guard mobileSyncOwnerID != workspaceID else { return } + if let previous = mobileSyncOwnerID, let previousState = appStatesByWorkspaceID[previous] { + previousState.unbindMobileSyncOwnership() + } + appState(for: workspaceID).bindMobileSyncOwnership() + mobileSyncOwnerID = workspaceID + + if !didStartMobileSync { + MobileSyncService.shared.start() + didStartMobileSync = true + } + } + + /// Drop a cached AppState (e.g. when its workspace is deleted). If the + /// deleted workspace was frontmost, fall back to Personal so global surfaces + /// resolve to a valid workspace. + func discard(_ workspaceID: String) { + appStatesByWorkspaceID[workspaceID]?.unbindMobileSyncOwnership() + appStatesByWorkspaceID[workspaceID] = nil + if mobileSyncOwnerID == workspaceID { + mobileSyncOwnerID = nil + } + if frontmostWorkspaceID == workspaceID { + markFrontmost(AppWorkspace.personalID) + } + } +} diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index e13dbf4b..a1bb85db 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1013,6 +1013,9 @@ } } } + }, + "Active" : { + }, "Add" : { "localizations" : { @@ -4647,6 +4650,12 @@ } } } + }, + "Create Workspace" : { + + }, + "Create Workspace..." : { + }, "Create worktree" : { "localizations" : { @@ -5379,6 +5388,9 @@ } } } + }, + "Delete Workspace" : { + }, "Deleting \"%@\"…" : { "localizations" : { @@ -6880,50 +6892,50 @@ } } }, - "Format the JSON body." : { + "Force off" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 본문의 형식을 지정합니다." + "value" : "강제 끄기" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "格式化 JSON 正文。" + "value" : "强制关闭" } } } }, - "Force off" : { + "Force on" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "강제 끄기" + "value" : "강제 켜기" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "强制关闭" + "value" : "强制开启" } } } }, - "Force on" : { + "Format the JSON body." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "강제 켜기" + "value" : "JSON 본문의 형식을 지정합니다." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "强制开启" + "value" : "格式化 JSON 正文。" } } } @@ -8775,6 +8787,12 @@ } } } + }, + "Manage Workspaces" : { + + }, + "Manage Workspaces..." : { + }, "Manage your repositories, environments, and secrets." : { "localizations" : { @@ -11555,34 +11573,34 @@ } } }, - "Prettify" : { + "Press Command-K or use the toolbar search button to find past work across projects." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서식 지정" + "value" : "Command-K를 누르거나 도구 모음의 검색 버튼을 사용하여 프로젝트 전반의 이전 작업을 찾으세요." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "格式化" + "value" : "按 Command-K 或使用工具栏搜索按钮,在各项目中查找过去的工作。" } } } }, - "Press Command-K or use the toolbar search button to find past work across projects." : { + "Prettify" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Command-K를 누르거나 도구 모음의 검색 버튼을 사용하여 프로젝트 전반의 이전 작업을 찾으세요." + "value" : "서식 지정" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "按 Command-K 或使用工具栏搜索按钮,在各项目中查找过去的工作。" + "value" : "格式化" } } } @@ -12579,6 +12597,9 @@ } } } + }, + "Rename Workspace" : { + }, "Rename…" : { "localizations" : { @@ -15396,6 +15417,9 @@ } } } + }, + "Switch Workspace" : { + }, "Tap to answer" : { "comment" : "Notification body when Claude invokes AskUserQuestion.", @@ -15739,6 +15763,9 @@ } } } + }, + "This removes the workspace and its settings. Files on disk are not deleted." : { + }, "This session will be deleted. This action cannot be undone." : { "localizations" : { @@ -16885,6 +16912,9 @@ } } } + }, + "Workspace name" : { + }, "Wrote %@ to the project." : { "localizations" : { @@ -16968,4 +16998,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index aae3aa69..1be08d23 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -121,6 +121,11 @@ final class AppStateHookController: HookController { return app.threadStore.fetch(id: sessionId)?.skipHooks ?? false } + func sessionEndHooksSuppressed(sessionKey: String, sessionId: String) -> Bool { + guard let app else { return false } + return app.sessionEndHooksSuppressed(sessionKey: sessionKey, sessionId: sessionId) + } + func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)? { guard let app else { return nil } diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift index c0227a1e..6f67b7c8 100644 --- a/RxCode/Services/Hooks/HookManager.swift +++ b/RxCode/Services/Hooks/HookManager.swift @@ -66,6 +66,7 @@ final class HookManager { func dispatchBeforeSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { guard !threadSkipsHooks(payload.sessionKey, payload.sessionId) else { return HookAggregateResult.fold([]) } + guard !sessionEndHooksSuppressed(payload) else { return HookAggregateResult.fold([]) } var outcomes: [HookOutcome] = [] for hook in enabledHooks { outcomes.append(await hook.beforeSessionEnd(payload, controller: controller)) @@ -75,6 +76,7 @@ final class HookManager { func dispatchAfterSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { guard !threadSkipsHooks(payload.sessionKey, payload.sessionId) else { return HookAggregateResult.fold([]) } + guard !sessionEndHooksSuppressed(payload) else { return HookAggregateResult.fold([]) } var outcomes: [HookOutcome] = [] for hook in enabledHooks { outcomes.append(await hook.afterSessionEnd(payload, controller: controller)) @@ -82,6 +84,20 @@ final class HookManager { return HookAggregateResult.fold(outcomes) } + /// Centrally suppress *all* session-end hooks (code review, commit/push, send + /// message, user stop hooks) for a planning turn — plan mode or an undecided + /// `ExitPlanMode` plan. Sits beside `threadSkipsHooks` so both completion and + /// cancellation dispatch paths are covered without each call site repeating + /// the check. Callers still mark `stopHooksHandledStreamIds` around the + /// dispatch, so a skipped turn can't be re-fired by a later result/cancel. + private func sessionEndHooksSuppressed(_ payload: SessionEndPayload) -> Bool { + guard controller.sessionEndHooksSuppressed(sessionKey: payload.sessionKey, sessionId: payload.sessionId) else { + return false + } + logger.debug("[Hook] session-end hooks suppressed (plan mode / pending plan): sessionKey=\(payload.sessionKey, privacy: .public)") + return true + } + // MARK: - Repository func dispatchRepositoryAdded(_ payload: RepositoryPayload) async { diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index 5e95ac12..0391134a 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -11,10 +11,12 @@ import os /// native config as authoritative. actor MCPService { + private let baseURL: URL private let claudeService: ClaudeService private let logger = Logger(subsystem: "com.claudework", category: "MCPService") - init(claudeService: ClaudeService) { + init(baseURL: URL = AppSupport.bundleScopedURL, claudeService: ClaudeService) { + self.baseURL = baseURL self.claudeService = claudeService } @@ -342,11 +344,11 @@ actor MCPService { // MARK: - RxCode Config private func configURL() -> URL { - AppSupport.bundleScopedURL.appendingPathComponent("mcp.json") + baseURL.appendingPathComponent("mcp.json") } private func generatedConfigDirectory() -> URL { - AppSupport.bundleScopedURL.appendingPathComponent("GeneratedMCP", isDirectory: true) + baseURL.appendingPathComponent("GeneratedMCP", isDirectory: true) } private func loadConfig() throws -> MCPConfiguration { diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index 0bb16834..ae338037 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -25,7 +25,11 @@ actor MarketplaceService { private var cachedCatalog: [MarketplacePlugin] = [] private var cacheDate: Date? private let cacheTTL: TimeInterval = 300 // 5 minutes - private let configURL = AppSupport.bundleScopedURL.appendingPathComponent("skills.json") + private let configURL: URL + + init(baseURL: URL = AppSupport.bundleScopedURL) { + self.configURL = baseURL.appendingPathComponent("skills.json") + } /// Source repositories to scan. private static let sourceRepos: [(owner: String, repo: String, defaultCategory: String)] = [ diff --git a/RxCode/Services/MemoryService.swift b/RxCode/Services/MemoryService.swift index 19d73059..b3daacbf 100644 --- a/RxCode/Services/MemoryService.swift +++ b/RxCode/Services/MemoryService.swift @@ -39,6 +39,13 @@ actor MemoryService { logger.info("Loaded \(rows.count) memory rows, indexed=\(self.entries.count)") } + func restart(threadStore: ThreadStore) async { + entries.removeAll() + didStart = false + self.threadStore = nil + await start(threadStore: threadStore) + } + func allMemories() async -> [MemoryItem] { entries.values .map(\.item) diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index c94663a9..fd8d6f69 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -38,8 +38,8 @@ actor PersistenceService: AppStatePersistenceService { private let cliStore: CLISessionStore private let logger = Logger(subsystem: "com.claudework", category: "PersistenceService") - init(metaStore: SessionMetaStore, cliStore: CLISessionStore) { - self.baseURL = AppSupport.bundleScopedURL + init(metaStore: SessionMetaStore, cliStore: CLISessionStore, baseURL: URL = AppSupport.bundleScopedURL) { + self.baseURL = baseURL self.metaStore = metaStore self.cliStore = cliStore } @@ -283,7 +283,7 @@ actor PersistenceService: AppStatePersistenceService { /// Returns the on-disk URL for the cached ACP registry snapshot. The /// `ACPRegistryService` reads/writes this file directly. nonisolated func acpRegistrySnapshotURL() -> URL { - AppSupport.bundleScopedURL.appendingPathComponent("acp_registry.json") + baseURL.appendingPathComponent("acp_registry.json") } // MARK: - Private Helpers diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift index 7750ee84..b06febee 100644 --- a/RxCode/Services/RxAuthService.swift +++ b/RxCode/Services/RxAuthService.swift @@ -21,9 +21,10 @@ final class RxAuthService { /// Keychain service shared with RxAuthSwift's `KeychainTokenStorage`. The /// SDK stores `access_token`, `refresh_token`, and `expires_at` under this /// service; we read those items directly for the fast-path token check. - static let keychainService = "com.rxtech.rxcode.rxauth" + nonisolated static let defaultKeychainService = "com.rxtech.rxcode.rxauth" let manager: OAuthManager + let keychainService: String private let logger = Logger(subsystem: "com.claudework", category: "RxAuthService") /// In-flight token refresh shared by every concurrent `accessToken()` @@ -48,7 +49,8 @@ final class RxAuthService { /// read after a refresh no longer prompts either. private var cachedToken: (value: String, expiresAt: Date)? - init() { + init(keychainService: String = RxAuthService.defaultKeychainService) { + self.keychainService = keychainService let configuration = RxAuthConfiguration( issuer: Self.issuer, clientID: Self.clientID, @@ -67,7 +69,7 @@ final class RxAuthService { // Must match the `webcredentials:rxlab.app` entitlement and the // AASA file served at https://rxlab.app/.well-known/apple-app-site-association. passkeyRelyingPartyIdentifier: "rxlab.app", - keychainServiceName: Self.keychainService + keychainServiceName: keychainService ) self.manager = OAuthManager(configuration: configuration) } @@ -103,8 +105,8 @@ final class RxAuthService { // stored item was written by a build with a different signature; we seed // the in-memory cache from it so the next caller skips the keychain. if !forceRefresh, - let token = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), - let expiresAt = Self.readExpiry(service: Self.keychainService), + let token = KeychainBackedTokenReader.readAccessToken(service: keychainService), + let expiresAt = Self.readExpiry(service: keychainService), !Self.isExpiring(expiresAt) { cachedToken = (token, expiresAt) return token @@ -124,11 +126,11 @@ final class RxAuthService { /// the keychain items under the current binary's signature — so this read /// matches the ACL and does not prompt. private func seedCacheFromKeychain() -> String? { - guard let token = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService) else { + guard let token = KeychainBackedTokenReader.readAccessToken(service: keychainService) else { cachedToken = nil return nil } - cachedToken = (token, Self.readExpiry(service: Self.keychainService) ?? .distantPast) + cachedToken = (token, Self.readExpiry(service: keychainService) ?? .distantPast) return token } diff --git a/RxCode/Services/ThreadSearchService.swift b/RxCode/Services/ThreadSearchService.swift index e765ba0b..0c8ef2fe 100644 --- a/RxCode/Services/ThreadSearchService.swift +++ b/RxCode/Services/ThreadSearchService.swift @@ -63,9 +63,14 @@ actor ThreadSearchService { /// Backfill version sentinel. Bump when chunking or embedding model changes. private let backfillVersion = 2 private let backfillKey = "com.idealapp.RxCode.searchIndex.backfillVersion" + private var workspaceDefaults = WorkspaceDefaults(workspaceID: AppWorkspace.personalID) init() {} + func setWorkspaceDefaults(_ defaults: WorkspaceDefaults) async { + workspaceDefaults = defaults + } + // MARK: - Lifecycle /// Load any existing chunk rows into memory. Cheap: ~2KB per chunk. @@ -99,6 +104,13 @@ actor ThreadSearchService { logger.info("Loaded \(rows.count) embedding chunks across \(self.index.count) threads") } + func restart(threadStore: ThreadStore) async { + index.removeAll() + didStart = false + self.threadStore = nil + await start(threadStore: threadStore) + } + // MARK: - Indexing /// Index a thread's content. Replaces any prior chunks for that thread. @@ -171,7 +183,7 @@ actor ThreadSearchService { logger.info("Reindex starting: clearing index and embedding chunk store") index.removeAll() await MainActor.run { store.deleteAllEmbeddingChunks() } - UserDefaults.standard.removeObject(forKey: backfillKey) + workspaceDefaults.set(nil as String?, for: backfillKey) let summaries = await MainActor.run { loadAll() } let total = summaries.count @@ -189,7 +201,7 @@ actor ThreadSearchService { progress?(done, total) if done % 8 == 0 { await Task.yield() } } - UserDefaults.standard.set(backfillVersion, forKey: backfillKey) + workspaceDefaults.set(backfillVersion, for: backfillKey) logger.info("Reindex complete: processed=\(done), loadFailed=\(loadFailed), total=\(total)") } @@ -285,7 +297,7 @@ actor ThreadSearchService { logger.info("Backfill skipped: threadStore not set") return } - let stored = UserDefaults.standard.integer(forKey: backfillKey) + let stored = workspaceDefaults.int(for: backfillKey, default: 0) guard stored < backfillVersion else { logger.info("Backfill skipped: already ran at version \(stored) (current=\(self.backfillVersion))") return @@ -311,7 +323,7 @@ actor ThreadSearchService { // Yield occasionally so we don't starve the cooperative pool. if done % 8 == 0 { await Task.yield() } } - UserDefaults.standard.set(backfillVersion, forKey: backfillKey) + workspaceDefaults.set(backfillVersion, for: backfillKey) logger.info("Search backfill complete: indexed=\(done), alreadyIndexed=\(alreadyIndexed), loadFailed=\(loadFailed), total=\(summaries.count)") } diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 20ec56f9..5baa9454 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -42,9 +42,9 @@ final class ThreadStore { /// Convenience initializer creating its own `ModelContainer` rooted at the /// app's Application Support directory. - static func make() -> ThreadStore { + static func make(baseURL: URL = AppSupport.bundleScopedURL) -> ThreadStore { let schema = Self.schema - let url = Self.storeURL() + let url = Self.storeURL(baseURL: baseURL) let config = ModelConfiguration(schema: schema, url: url) do { let container = try ModelContainer(for: schema, configurations: [config]) @@ -64,9 +64,9 @@ final class ThreadStore { } } - private static func storeURL() -> URL { + private static func storeURL(baseURL: URL) -> URL { let fm = FileManager.default - let dir = AppSupport.bundleScopedURL + let dir = baseURL try? fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("threads.store") } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index fc9025fd..44a10040 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -10,6 +10,8 @@ struct MainView: View { @Environment(WindowState.self) private var windowState @State private var showGitHubSheet = false @State private var showFilePicker = false + @State private var showCreateWorkspaceSheet = false + @State private var showManageWorkspaceSheet = false @Environment(\.openSettings) private var openSettings @State private var sidebarTab: SidebarTab = .history @State private var fileSearchTrigger = false @@ -264,6 +266,22 @@ struct MainView: View { } .background(ClaudeTheme.sidebarBackground.ignoresSafeArea()) .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 500) + .toolbar { + ToolbarItem(placement: .automatic) { + WorkspaceSwitcher( + showingCreateSheet: $showCreateWorkspaceSheet, + showingManageSheet: $showManageWorkspaceSheet + ) + } + } + .sheet(isPresented: $showCreateWorkspaceSheet) { + CreateWorkspaceSheet() + .environment(appState) + } + .sheet(isPresented: $showManageWorkspaceSheet) { + ManageWorkspacesSheet() + .environment(appState) + } .sheet(isPresented: $showGitHubSheet) { AutopilotRepoSheet() } @@ -498,7 +516,7 @@ struct ProjectTabButton: View { } .buttonStyle(.plain) .onTapGesture(count: 2) { - openWindow(id: "project-window", value: ProjectWindowValue(projectId: project.id, instanceId: UUID())) + openWindow(id: "project-window", value: ProjectWindowValue(projectId: project.id, instanceId: UUID(), workspaceID: appState.activeWorkspace.id)) } .contextMenu { let hookItems = appState.projectContextMenuItems(for: project) diff --git a/RxCode/Views/Onboarding/OnboardingView.swift b/RxCode/Views/Onboarding/OnboardingView.swift index 8a41e520..8d7248c6 100644 --- a/RxCode/Views/Onboarding/OnboardingView.swift +++ b/RxCode/Views/Onboarding/OnboardingView.swift @@ -360,7 +360,7 @@ struct OnboardingView: View { appState.openAISummarizationAPIKey = summarizationAPIKeyDraft } appState.onboardingCompleted = true - UserDefaults.standard.set(true, forKey: "onboardingCompleted") + appState.workspaceDefaults.set(true, for: "onboardingCompleted") onCompletion?() } diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index 6d9dcef1..deec6c35 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -274,10 +274,9 @@ struct ProjectTreeView: View { } ), isSelected: windowState.selectedProject?.id == project.id, - onSelectProject: { appState.selectProject(project, in: windowState) }, onOpenInNewWindow: { openWindow(id: "project-window", - value: ProjectWindowValue(projectId: project.id, instanceId: UUID())) + value: ProjectWindowValue(projectId: project.id, instanceId: UUID(), workspaceID: appState.activeWorkspace.id)) }, onRename: { renameProjectText = project.name @@ -340,7 +339,6 @@ private struct ProjectTreeRow: View { let project: Project @Binding var isExpanded: Bool let isSelected: Bool - let onSelectProject: () -> Void let onOpenInNewWindow: () -> Void let onRename: () -> Void let onDelete: () -> Void @@ -464,8 +462,9 @@ private struct ProjectTreeRow: View { .contentShape(Rectangle()) .onHover { isHovered = $0 } .onTapGesture { - onSelectProject() - if !isExpanded { isExpanded = true } + withAnimation(.easeInOut(duration: 0.18)) { + isExpanded.toggle() + } } .onTapGesture(count: 2) { onOpenInNewWindow() diff --git a/RxCode/Views/WorkspaceSwitcher.swift b/RxCode/Views/WorkspaceSwitcher.swift new file mode 100644 index 00000000..5aaffe5d --- /dev/null +++ b/RxCode/Views/WorkspaceSwitcher.swift @@ -0,0 +1,242 @@ +import RxCodeCore +import SwiftUI + +struct WorkspaceSwitcher: View { + @Environment(AppState.self) private var appState + @Environment(\.openWindow) private var openWindow + @Binding var showingCreateSheet: Bool + @Binding var showingManageSheet: Bool + + var body: some View { + Menu { + ForEach(appState.workspaces) { workspace in + Button { + switchTo(workspace) + } label: { + if workspace.id == appState.activeWorkspace.id { + Label(workspace.name, systemImage: "checkmark") + } else { + Text(workspace.name) + } + } + .disabled(workspace.id == appState.activeWorkspace.id) + } + + Divider() + + Button { + showingCreateSheet = true + } label: { + Label("Create Workspace...", systemImage: "plus") + } + + Button { + showingManageSheet = true + } label: { + Label("Manage Workspaces...", systemImage: "slider.horizontal.3") + } + } label: { + HStack(spacing: 6) { + Image(systemName: "person.fill") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + Text(appState.activeWorkspace.name) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: "chevron.down") + .font(.system(size: ClaudeTheme.size(9), weight: .bold)) + .foregroundStyle(.secondary) + } + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .frame(maxWidth: 180) + } + .help("Switch Workspace") + } + + /// Switching to another workspace opens (or refocuses) that workspace's own + /// window, leaving the current window untouched. The registry's active + /// workspace is updated so the next launch restores the same one. + private func switchTo(_ workspace: AppWorkspace) { + guard workspace.id != appState.activeWorkspace.id else { return } + appState.activateWorkspace(id: workspace.id) + openWindow(id: "workspace-window", value: WorkspaceWindowValue(workspaceID: workspace.id)) + } +} + +struct CreateWorkspaceSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.openWindow) private var openWindow + @Environment(\.dismiss) private var dismiss + @State private var name = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Create Workspace") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .foregroundStyle(.secondary) + TextField("Workspace name", text: $name) + .textFieldStyle(.roundedBorder) + .onSubmit(create) + } + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + Button("Create") { + create() + } + .keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 360) + } + + private func create() { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let workspace = appState.createWorkspace(named: trimmed) + appState.activateWorkspace(id: workspace.id) + openWindow(id: "workspace-window", value: WorkspaceWindowValue(workspaceID: workspace.id)) + dismiss() + } +} + +struct ManageWorkspacesSheet: View { + @Environment(AppState.self) private var appState + @Environment(WorkspaceManager.self) private var workspaceManager + @Environment(\.dismissWindow) private var dismissWindow + @Environment(\.dismiss) private var dismiss + + @State private var editingID: String? + @State private var editingName: String = "" + @State private var deleteCandidate: AppWorkspace? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Manage Workspaces") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + + ScrollView { + VStack(spacing: 6) { + ForEach(appState.workspaces) { workspace in + row(workspace) + } + } + } + .frame(maxHeight: 320) + + HStack { + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 420) + .confirmationDialog( + "Delete \"\(deleteCandidate?.name ?? "")\"?", + isPresented: Binding( + get: { deleteCandidate != nil }, + set: { if !$0 { deleteCandidate = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let workspace = deleteCandidate { + delete(workspace) + } + deleteCandidate = nil + } + Button("Cancel", role: .cancel) { deleteCandidate = nil } + } message: { + Text("This removes the workspace and its settings. Files on disk are not deleted.") + } + } + + @ViewBuilder + private func row(_ workspace: AppWorkspace) -> some View { + HStack(spacing: 8) { + Image(systemName: "person.fill") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18) + + if editingID == workspace.id { + TextField("Workspace name", text: $editingName) + .textFieldStyle(.roundedBorder) + .onSubmit { commitRename(workspace) } + Button("Save") { commitRename(workspace) } + .disabled(editingName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { editingID = nil } + } else { + Text(workspace.name) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .lineLimit(1) + + if workspace.id == appState.activeWorkspace.id { + Text("Active") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + if workspace.isPersonal { + Text("Default") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.tertiary) + } else { + Button { + startRename(workspace) + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Rename Workspace") + + Button { + deleteCandidate = workspace + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + .help("Delete Workspace") + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(Color.primary.opacity(0.04)) + ) + } + + private func startRename(_ workspace: AppWorkspace) { + editingID = workspace.id + editingName = workspace.name + } + + private func commitRename(_ workspace: AppWorkspace) { + let trimmed = editingName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + appState.renameWorkspace(id: workspace.id, to: trimmed) + editingID = nil + } + + /// Remove the workspace from the registry, then close its window (if open) + /// and drop its cached AppState so its services tear down. + private func delete(_ workspace: AppWorkspace) { + guard appState.deleteWorkspace(id: workspace.id) else { return } + dismissWindow(id: "workspace-window", value: WorkspaceWindowValue(workspaceID: workspace.id)) + workspaceManager.discard(workspace.id) + } +} diff --git a/RxCodeTests/PlanCardViewTests.swift b/RxCodeTests/PlanCardViewTests.swift index d4a38457..efd624dc 100644 --- a/RxCodeTests/PlanCardViewTests.swift +++ b/RxCodeTests/PlanCardViewTests.swift @@ -11,6 +11,23 @@ extension PlanSheetInspection: InspectionEmissary {} @MainActor final class PlanCardViewTests: XCTestCase { + /// `ViewHosting.expel()` removes the host view but AppKit tears the backing + /// `NSHostingView` down asynchronously. `PlanSheetView`'s `PlanSheetInspection` + /// has a main-actor back-deployed `deinit`, so a deferred teardown can hop onto + /// the main actor *during an unrelated later test* and double-free on the Swift + /// concurrency runtime (`POINTER_BEING_FREED_WAS_NOT_ALLOCATED`), crashing the + /// whole test process. Pump the main run loop here so every hosted view this + /// test mounted is fully deallocated within this test's own context, never + /// leaking into a sibling suite. + override func tearDown() { + // Pump the main run loop so AppKit flushes every pending `NSHostingView` + // teardown (and its main-actor deinit) before the next test starts. + for _ in 0..<5 { + _ = CFRunLoopRunInMode(.defaultMode, 0.02, false) + } + super.tearDown() + } + // MARK: - PlanCardView chip: tapping "Review" calls onOpen with tool call id func testChip_tappingReview_callsOnOpenWithToolCallId() throws { diff --git a/RxCodeTests/PlanModeHookSuppressionTests.swift b/RxCodeTests/PlanModeHookSuppressionTests.swift new file mode 100644 index 00000000..ce5accc0 --- /dev/null +++ b/RxCodeTests/PlanModeHookSuppressionTests.swift @@ -0,0 +1,166 @@ +import XCTest +import RxCodeCore +@testable import RxCode + +/// Verifies that session-end lifecycle hooks (code review, commit/push, send +/// message, user stop hooks) are centrally suppressed for *planning* turns — +/// plan mode or an undecided `ExitPlanMode` plan — across both the before- and +/// after-session-end dispatch paths, while ordinary turns still dispatch. +@MainActor +final class PlanModeHookSuppressionTests: XCTestCase { + + /// Records whether the session-end dispatch reached the registered hooks. + private final class SpyStopHook: Hook { + let hookID = "test.spyStop" + private(set) var beforeCalled = false + private(set) var afterCalled = false + + func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + beforeCalled = true + return .ignored + } + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + afterCalled = true + return .ignored + } + } + + private var appState: AppState! + private var spy: SpyStopHook! + /// A manager wired to the real controller but holding only the spy, so the + /// assertions observe the suppression guard without the built-in hooks' + /// side effects (notifications, disk reads). + private var manager: HookManager! + + override func setUp() async throws { + appState = AppState(startBackgroundServices: false) + spy = SpyStopHook() + manager = HookManager(controller: appState.hookController) + manager.register(spy) + } + + override func tearDown() async throws { + appState = nil + spy = nil + manager = nil + } + + // MARK: - Helpers + + private func makeProject(_ name: String = "P") -> Project { + Project(name: name, path: "/tmp/\(name.lowercased())", gitHubRepo: nil) + } + + private func makePayload(sessionKey: String, reason: SessionEndReason = .completed) -> SessionEndPayload { + SessionEndPayload( + project: makeProject(), + sessionKey: sessionKey, + sessionId: sessionKey, + reason: reason, + turnDidError: false, + lastAssistantText: "" + ) + } + + private func exitPlanMessage(decisionResult: String? = nil, toolId: String = "plan-1") -> ChatMessage { + let toolCall = ToolCall(id: toolId, name: "ExitPlanMode", result: decisionResult) + return ChatMessage(role: .assistant, blocks: [.toolCall(toolCall)]) + } + + // MARK: - Plan mode + + func testPlanMode_suppressesBeforeAndAfterSessionEndHooks() async { + let key = "plan-mode-session" + var state = SessionStreamState() + state.planMode = true + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key)) + + XCTAssertFalse(spy.beforeCalled, "before-session-end hooks must not run in plan mode") + XCTAssertFalse(spy.afterCalled, "after-session-end hooks must not run in plan mode") + } + + func testPlanMode_suppressesOnCancellationPath() async { + let key = "plan-mode-cancel" + var state = SessionStreamState() + state.planMode = true + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key, reason: .cancelled)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key, reason: .cancelled)) + + XCTAssertFalse(spy.beforeCalled, "plan-mode cancellation must not run stop hooks") + XCTAssertFalse(spy.afterCalled, "plan-mode cancellation must not run stop hooks") + } + + // MARK: - Pending / ready plan + + func testPendingExitPlan_suppressesHooks() async { + let key = "ready-plan-session" + var state = SessionStreamState() + state.messages = [exitPlanMessage(decisionResult: nil)] // emitted but undecided + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key)) + + XCTAssertFalse(spy.beforeCalled, "an undecided ExitPlanMode plan must suppress stop hooks") + XCTAssertFalse(spy.afterCalled, "an undecided ExitPlanMode plan must suppress stop hooks") + } + + func testPendingExitPlan_viaSidecarDecision_doesNotSuppress() async { + // Plan decided through the planDecisionSummaries sidecar (the CLI reload + // path) — hooks should resume. + let key = "sidecar-decided-session" + var state = SessionStreamState() + state.messages = [exitPlanMessage(decisionResult: nil, toolId: "plan-x")] + state.planDecisionSummaries = ["plan-x": "Accepted with Ask"] + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key)) + + XCTAssertTrue(spy.beforeCalled, "a sidecar-decided plan should no longer suppress hooks") + XCTAssertTrue(spy.afterCalled, "a sidecar-decided plan should no longer suppress hooks") + } + + // MARK: - Control cases (must still dispatch) + + func testDecidedPlan_doesNotSuppress() async { + let key = "decided-plan-session" + var state = SessionStreamState() + state.messages = [exitPlanMessage(decisionResult: "Accepted with Ask")] + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key)) + + XCTAssertTrue(spy.beforeCalled, "an accepted plan should dispatch stop hooks") + XCTAssertTrue(spy.afterCalled, "an accepted plan should dispatch stop hooks") + } + + func testOrdinaryCompletion_dispatchesHooks() async { + let key = "ordinary-session" + var state = SessionStreamState() + state.messages = [ChatMessage(role: .assistant, content: "done")] + appState.sessionStates[key] = state + + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: key)) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: key)) + + XCTAssertTrue(spy.beforeCalled, "a normal completion must dispatch stop hooks") + XCTAssertTrue(spy.afterCalled, "a normal completion must dispatch stop hooks") + } + + func testNoSessionState_dispatchesHooks() async { + // No state recorded for the key → not a planning turn → hooks run. + _ = await manager.dispatchBeforeSessionEnd(makePayload(sessionKey: "unknown")) + _ = await manager.dispatchAfterSessionEnd(makePayload(sessionKey: "unknown")) + + XCTAssertTrue(spy.beforeCalled) + XCTAssertTrue(spy.afterCalled) + } +} diff --git a/RxCodeTests/WorkspaceTests.swift b/RxCodeTests/WorkspaceTests.swift new file mode 100644 index 00000000..10c41877 --- /dev/null +++ b/RxCodeTests/WorkspaceTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import RxCode + +final class WorkspaceTests: XCTestCase { + func testRegistryCreatesPersonalWorkspaceByDefault() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rxcode-workspaces-\(UUID().uuidString)") + .appendingPathComponent("workspaces.json") + let registry = WorkspaceRegistry(url: url) + + let snapshot = registry.load() + + XCTAssertEqual(snapshot.active.id, AppWorkspace.personalID) + XCTAssertEqual(snapshot.active.name, "Personal") + XCTAssertEqual(snapshot.all.map(\.id), [AppWorkspace.personalID]) + } + + func testRegistryCreatesAndSwitchesWorkspace() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rxcode-workspaces-\(UUID().uuidString)") + .appendingPathComponent("workspaces.json") + let registry = WorkspaceRegistry(url: url) + + let created = registry.createWorkspace(name: "Work") + XCTAssertEqual(created.active.name, "Work") + XCTAssertEqual(created.all.count, 2) + + let switched = registry.switchWorkspace(id: AppWorkspace.personalID) + XCTAssertEqual(switched?.active.id, AppWorkspace.personalID) + } + + func testWorkspaceDefaultsNamespaceNonPersonalKeys() { + let key = "workspaceTest.\(UUID().uuidString)" + let workspaceID = UUID().uuidString.lowercased() + let personal = WorkspaceDefaults(workspaceID: AppWorkspace.personalID) + let workspace = WorkspaceDefaults(workspaceID: workspaceID) + + defer { + UserDefaults.standard.removeObject(forKey: key) + UserDefaults.standard.removeObject(forKey: "workspace.\(workspaceID).\(key)") + } + + personal.set("personal", for: key) + workspace.set("work", for: key) + + XCTAssertEqual(UserDefaults.standard.string(forKey: key), "personal") + XCTAssertEqual(UserDefaults.standard.string(forKey: "workspace.\(workspaceID).\(key)"), "work") + XCTAssertEqual(personal.string(for: key), "personal") + XCTAssertEqual(workspace.string(for: key), "work") + } +} diff --git a/scripts/ci/sign-sparkle.sh b/scripts/ci/sign-sparkle.sh index d9de6be4..9bc6ca7a 100755 --- a/scripts/ci/sign-sparkle.sh +++ b/scripts/ci/sign-sparkle.sh @@ -20,10 +20,33 @@ codesign --force --options runtime --timestamp --sign "${SIGNING_CERTIFICATE_NAM # Sign the Sparkle framework as a whole codesign --force --options runtime --timestamp --sign "${SIGNING_CERTIFICATE_NAME}" "$APP_PATH/Contents/Frameworks/Sparkle.framework" -# Re-sign the main app binary -codesign --force --options runtime --timestamp --sign "${SIGNING_CERTIFICATE_NAME}" "$APP_PATH/Contents/MacOS/RxCode" +# Capture the entitlements xcodebuild embedded in the archive BEFORE re-signing +# the main app. `codesign --force` without --entitlements drops them, which +# strips com.apple.developer.associated-domains and breaks passkeys/webcredentials +# (app reported as "not associated with domain rxlab.app"). We re-apply the exact +# archived entitlements (which also carry the profile-injected application-identifier +# and team-identifier) so the resealed binary keeps them. +ENTITLEMENTS_PLIST="${RUNNER_TEMP:-/tmp}/RxCode-app.entitlements.plist" +codesign -d --entitlements "$ENTITLEMENTS_PLIST" --xml "$APP_PATH" 2>/dev/null + +if [ ! -s "$ENTITLEMENTS_PLIST" ]; then + echo "Error: failed to extract entitlements from archived app; aborting to avoid shipping an app without associated-domains" + exit 1 +fi + +echo "Preserving archived entitlements:" +/usr/bin/plutil -p "$ENTITLEMENTS_PLIST" || true + +# Re-sign the main app binary, re-applying the archived entitlements +codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PLIST" --sign "${SIGNING_CERTIFICATE_NAME}" "$APP_PATH/Contents/MacOS/RxCode" -# Re-sign the main app to ensure everything is properly signed -codesign --force --options runtime --timestamp --sign "${SIGNING_CERTIFICATE_NAME}" "$APP_PATH" +# Re-sign the main app to ensure everything is properly signed, keeping entitlements +codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PLIST" --sign "${SIGNING_CERTIFICATE_NAME}" "$APP_PATH" + +# Verify the resealed app still declares associated-domains +if ! codesign -d --entitlements - --xml "$APP_PATH" 2>/dev/null | grep -q "com.apple.developer.associated-domains"; then + echo "Error: associated-domains entitlement missing after re-signing" + exit 1 +fi echo "Signing completed successfully"