diff --git a/Packages/Sources/RxCodeChatKit/IMETextView.swift b/Packages/Sources/RxCodeChatKit/IMETextView.swift index d1c69c4..5de689d 100644 --- a/Packages/Sources/RxCodeChatKit/IMETextView.swift +++ b/Packages/Sources/RxCodeChatKit/IMETextView.swift @@ -73,6 +73,18 @@ struct IMETextView: NSViewRepresentable { return scrollView } + static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) { + guard let textView = nsView.documentView as? _IMETextView else { return } + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + textView.delegate = nil + textView.clearCallbacks() + textView.undoManager?.removeAllActions(withTarget: textView) + textView.allowsUndo = false + nsView.documentView = nil + } + func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? _IMETextView else { return } applyCallbacks(to: textView) @@ -225,6 +237,19 @@ fileprivate final class _IMETextView: NSTextView { var onMarkedTextChange: (Bool) -> Void = { _ in } var onFocusChange: (Bool) -> Void = { _ in } + func clearCallbacks() { + onReturn = {} + onUpArrow = { false } + onDownArrow = { false } + onTab = { false } + onShiftTab = {} + onEscape = { false } + onPasteCommandV = { false } + onImageChipTap = nil + onMarkedTextChange = { _ in } + onFocusChange = { _ in } + } + override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result { onFocusChange(true) } diff --git a/Packages/Sources/RxCodeCore/Models/ChatThread.swift b/Packages/Sources/RxCodeCore/Models/ChatThread.swift index 5f8adc3..b0bfa54 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatThread.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatThread.swift @@ -12,6 +12,15 @@ public final class ChatThread { public var cliSessionId: String? + /// Per-provider native session/resume ids for this thread, keyed by + /// `AgentProvider.rawValue` and JSON-encoded. Lets a single thread carry a + /// distinct backend session id per provider it has run under, so switching + /// provider (e.g. Claude ↔ Codex) resumes each provider's own native + /// session instead of feeding one provider another's id. Defaulted for + /// clean SwiftData migration; deliberately kept out of `apply()`/`toSummary` + /// (like `cliSessionId`) so summary upserts never wipe it. + public var providerSessionIdsJSON: String? = nil + public var agentProviderRaw: String? public var originRaw: String? public var model: String? @@ -81,6 +90,22 @@ public final class ChatThread { } extension ChatThread { + /// Decoded view of `providerSessionIdsJSON`. Reads/writes the JSON blob so + /// callers work with a plain `[providerRawValue: nativeSessionId]` map. + public var providerSessionIds: [String: String] { + get { + guard let json = providerSessionIdsJSON, + let data = json.data(using: .utf8), + let map = try? JSONDecoder().decode([String: String].self, from: data) + else { return [:] } + return map + } + set { + providerSessionIdsJSON = (try? JSONEncoder().encode(newValue)) + .flatMap { String(data: $0, encoding: .utf8) } + } + } + public func toSummary() -> ChatSession.Summary { let agentProvider = agentProviderRaw.flatMap(AgentProvider.init(rawValue:)) ?? .claudeCode let origin = originRaw.flatMap(SessionOrigin.init(rawValue:)) diff --git a/Packages/Sources/RxCodeEditor/CodeEditorView.swift b/Packages/Sources/RxCodeEditor/CodeEditorView.swift index adc4dd4..7f9c7f1 100644 --- a/Packages/Sources/RxCodeEditor/CodeEditorView.swift +++ b/Packages/Sources/RxCodeEditor/CodeEditorView.swift @@ -72,6 +72,20 @@ public struct CodeEditorView: NSViewRepresentable { return scrollView } + public static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) { + guard let textView = nsView.documentView as? CompletionTextView else { return } + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + textView.delegate = nil + textView.completionRangeProvider = nil + textView.completionRangeOverride = nil + textView.undoManager?.removeAllActions(withTarget: textView) + textView.allowsUndo = false + coordinator.textView = nil + nsView.documentView = nil + } + public func updateNSView(_ scrollView: NSScrollView, context: Context) { context.coordinator.parent = self guard let textView = context.coordinator.textView else { return } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 707143e..60f45d9 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1409,7 +1409,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = P9KK452K8P; @@ -1428,7 +1429,7 @@ MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = RxCode; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index d9bfab7..d4acc07 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -151,11 +151,12 @@ extension AppState { // CLI session). Their stdout is injected into this turn's agent context // the same way the branch briefing / memory context is, and the status // cards inserted by UserAddedHook persist via the `.result` save. + // + // Project-new-chat hooks update passive banners and are driven by the + // visible chat views. Keep them out of this preflight path: awaiting + // network-backed banner checks here delays the first backend event. var hookStartContext = "" if cliSessionId == nil, let project = projects.first(where: { $0.id == projectId }) { - await hookManager.dispatchProjectNewChatStart( - NewChatStartPayload(projectId: projectId, sessionKey: sessionKey) - ) hookStartContext = await hookManager.dispatchSessionStart( SessionStartPayload(project: project, sessionKey: sessionKey) ).combinedOutput @@ -325,16 +326,18 @@ extension AppState { if case .result(let resultEvent) = event { logger.info("[Stream:UI] event #\(eventCount) .result received after losing ownership — saving to disk") await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) - if sessionKey != resultEvent.sessionId { + if sessionKey != resultEvent.sessionId, + providerOwnsThreadId(agentProvider, threadId: sessionKey) { if let state = sessionStates.removeValue(forKey: sessionKey) { sessionStates[resultEvent.sessionId] = state } applySessionIdRedirect(from: sessionKey, to: resultEvent.sessionId) sessionKey = resultEvent.sessionId } + recordProviderSessionId(agentProvider, nativeId: resultEvent.sessionId, threadId: sessionKey) let msgs = stateForSession(sessionKey).messages if !msgs.isEmpty { - await saveSession(sessionId: resultEvent.sessionId, projectId: projectId, messages: msgs) + await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) } } else { logger.debug("[Stream:UI] event #\(eventCount) — stream \(streamId) no longer owns session \(sessionKey), skipping") @@ -354,6 +357,17 @@ extension AppState { let isHookEvent = systemEvent.subtype.hasPrefix("hook_") if let sid = systemEvent.sessionId, !isHookEvent { await permission.registerSession(sid: sid, projectKey: cwd, mode: registerMode) + // Only the provider that owns the thread id may re-home the + // thread when its native `session_id` changes. A provider + // the user switched to mid-thread (e.g. Codex on a Claude + // thread) reports its OWN native id here — that must not + // rename/reconcile the thread; it's just recorded so a + // later switch-back can resume it. `recordProviderSessionId` + // runs in both cases below. + let streamPlaceholder = "pending-\(streamId.uuidString)" + let ownsThreadId = window.pendingPlaceholderIds.contains(streamPlaceholder) + || providerOwnsThreadId(agentProvider, threadId: sessionKey) + if ownsThreadId { // Capture the sessionKey BEFORE the reassignment so the // reconciler can rename the previous row in place when // the CLI advances `session_id` mid-stream. @@ -490,6 +504,12 @@ extension AppState { if previousSessionKey != sid { broadcastMobileSessionRedirect(from: previousSessionKey, to: sid) } + } // end ownsThreadId + // Record this provider's native session id — for the owner + // on the possibly-renamed thread id, for a switched provider + // on the stable thread id — so each provider can later + // resume its own native session. + recordProviderSessionId(agentProvider, nativeId: sid, threadId: sessionKey) } if systemEvent.subtype == "compact_boundary" { @@ -625,7 +645,12 @@ extension AppState { // any subagent children that survived the parent CLI get reaped. await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) - if sessionKey != resultEvent.sessionId { + // Only the provider that owns the thread id may re-home the + // thread. A switched provider's differing native id is recorded + // (below) but must not rename the thread. + let resultOwnsThreadId = window.pendingPlaceholderIds.contains("pending-\(streamId.uuidString)") + || providerOwnsThreadId(agentProvider, threadId: sessionKey) + if sessionKey != resultEvent.sessionId && resultOwnsThreadId { let previousSessionKey = sessionKey let wasForeground = (window.currentSessionId ?? window.newSessionKey) == previousSessionKey if let state = sessionStates.removeValue(forKey: previousSessionKey) { @@ -674,6 +699,11 @@ extension AppState { broadcastMobileSessionRedirect(from: previousSessionKey, to: resultEvent.sessionId) } + // Record this provider's native session id (owner: on the + // renamed thread id; switched provider: on the stable thread + // id) so each provider can resume its own native session. + recordProviderSessionId(agentProvider, nativeId: resultEvent.sessionId, threadId: sessionKey) + // A background completion is "finished, unread". Setting the // flag inside finalizeStreamSession means the trailing // `.streamingFinished` broadcast already carries it to mobile. @@ -707,7 +737,7 @@ extension AppState { // `ide__send_to_thread` JSON-RPC response. recordStreamCompletion( streamId: streamId, - sessionId: resultEvent.sessionId, + sessionId: sessionKey, assistantText: finalAssistantText, error: resultEvent.isError ? "Agent reported an error result." : nil ) @@ -738,7 +768,7 @@ extension AppState { } if isFg { - window.currentSessionId = resultEvent.sessionId + window.currentSessionId = sessionKey if resultEvent.isError { let errText = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) ?? "\(agentProvider.displayNameText) returned an error." @@ -747,7 +777,7 @@ extension AppState { } await saveSession( - sessionId: resultEvent.sessionId, + sessionId: sessionKey, projectId: projectId, messages: stateForSession(sessionKey).messages ) @@ -817,13 +847,13 @@ extension AppState { // leak into the thread summary and extracted memories. if !wasHookInjectedTurn { scheduleThreadSummaryUpdate( - sessionId: resultEvent.sessionId, + sessionId: sessionKey, projectId: projectId, cwd: cwd, messages: stateForSession(sessionKey).messages ) scheduleMemoryExtraction( - sessionId: resultEvent.sessionId, + sessionId: sessionKey, projectId: projectId, messages: stateForSession(sessionKey).messages ) diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 2f7e9c5..1bed007 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -51,6 +51,7 @@ extension AppState { // Resolve target project + thread. let resolvedProject: Project let resolvedThreadId: String? + let resolvedThreadSummary: ChatSession.Summary? if let threadId { guard let summary = allSessionSummaries.first(where: { $0.id == threadId }) @@ -63,12 +64,14 @@ extension AppState { } resolvedProject = proj resolvedThreadId = threadId + resolvedThreadSummary = summary } else if let projectId { guard let proj = projects.first(where: { $0.id == projectId }) else { throw CrossProjectSendError.unknownProject(projectId) } resolvedProject = proj resolvedThreadId = nil + resolvedThreadSummary = nil } else { throw CrossProjectSendError.unknownProject(UUID()) } @@ -81,10 +84,32 @@ extension AppState { window.selectedProject = resolvedProject window.currentSessionId = resolvedThreadId - // Carry over per-session overrides for a new thread; for an existing - // thread we leave the session's own stored values alone (the resume - // path in sendPrompt reads from `sessionStates[sessionKey]`). - if resolvedThreadId == nil { + if let resolvedThreadId, let summary = resolvedThreadSummary { + let existingState = sessionStates[resolvedThreadId] + let resolvedProvider = agentProvider + ?? existingState?.agentProvider + ?? summary.agentProvider + let resolvedModel = model + ?? existingState?.model + ?? summary.model + window.sessionAgentProvider = resolvedProvider + window.sessionModel = resolvedModel + window.sessionEffort = effort ?? existingState?.effort ?? summary.effort + window.sessionPermissionMode = permissionMode ?? existingState?.permissionMode ?? summary.permissionMode + + if agentProvider != nil || model != nil || effort != nil || permissionMode != nil || existingState == nil { + updateState(resolvedThreadId) { state in + state.agentProvider = resolvedProvider + state.model = resolvedModel + if let effort = window.sessionEffort { state.effort = effort } + if let permissionMode = window.sessionPermissionMode { state.permissionMode = permissionMode } + if state.providerSessionIds.isEmpty { + state.providerSessionIds = threadStore.providerSessionIds(forLocalId: resolvedThreadId) + } + } + } + } else { + // Carry over per-session overrides for a new thread. if let agentProvider { window.sessionAgentProvider = agentProvider } diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift index 8b77e7d..67fb0f9 100644 --- a/RxCode/App/AppState+Helpers.swift +++ b/RxCode/App/AppState+Helpers.swift @@ -254,8 +254,20 @@ extension AppState { ?? summary?.effort let sessionPermissionMode = sessionStates[sessionId]?.permissionMode ?? summary?.permissionMode - let origin = summary?.origin + var origin = summary?.origin ?? sessionAgentProvider.defaultSessionOrigin + // A thread that started under Claude (`.cliBacked`, where the CLI's jsonl + // owns the message log and RxCode persists no messages of its own) but is + // now running a different provider must migrate to RxCode's own message + // store — otherwise the non-Claude turns are never persisted and vanish on + // reload. The full in-memory history (including the Claude-era turns, which + // are loaded before a switch) is written here under the new origin, + // preserving continuity. Guarded so a mid-load race can't lock in a + // truncated history. + let isLoadingFromDisk = sessionStates[sessionId]?.isLoadingFromDisk ?? false + if origin == .cliBacked, sessionAgentProvider != .claudeCode, !isLoadingFromDisk { + origin = .legacyRxCode + } let session = ChatSession( id: sessionId, projectId: projectId, diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 5d33e6e..835a4d3 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -232,7 +232,17 @@ extension AppState { } let isNewSession = window.currentSessionId == nil let isPending = window.currentSessionId.map { window.pendingPlaceholderIds.contains($0) } ?? false - let cliSessionId: String? = (isNewSession || isPending) ? nil : window.currentSessionId + // Resolve the resume id per provider: a thread that ran under Claude and + // is now switched to Codex must NOT hand Codex the Claude session id. + // The switched provider resumes its own recorded native id, or starts a + // fresh native session when it has never run on this thread. + let cliSessionId: String? + if isNewSession || isPending { + cliSessionId = nil + } else { + let resumeProvider = effectiveModelSelection(in: window).provider + cliSessionId = resumeSessionId(for: resumeProvider, threadId: window.currentSessionId!) + } if let debugLogPrefix { logger.info("\(debugLogPrefix, privacy: .public) phase=sendPrompt stream=\(streamId) isNewSession=\(isNewSession, privacy: .public) isPending=\(isPending, privacy: .public) cliSession=\(cliSessionId ?? "", privacy: .public)") } @@ -642,6 +652,57 @@ extension AppState { /// `session_id`). Mirror writes to `sessionIdRedirect` already; this also /// resumes any `awaitSessionRename` caller so the cross-project send can /// return the real thread id instead of `pending-…`. + // MARK: - Per-provider native session ids + + /// Record a backend-reported native session id for `provider` on `threadId`, + /// updating both the in-memory session state and the persisted thread row so + /// a later provider switch can resume the correct native session (or, for a + /// provider that has never run on this thread, start a fresh one). + func recordProviderSessionId(_ provider: AgentProvider, nativeId: String, threadId: String) { + guard !nativeId.isEmpty else { return } + updateState(threadId) { state in + state.providerSessionIds[provider.rawValue] = nativeId + } + threadStore.setProviderSessionId( + localId: threadId, + providerRaw: provider.rawValue, + nativeId: nativeId + ) + } + + /// The per-provider native-id map for a thread, preferring in-memory session + /// state and falling back to the persisted row. + func providerSessionIdMap(for threadId: String) -> [String: String] { + let inMemory = sessionStates[threadId]?.providerSessionIds ?? [:] + if !inMemory.isEmpty { return inMemory } + return threadStore.providerSessionIds(forLocalId: threadId) + } + + /// Whether `provider` owns `threadId`'s identity — i.e. the thread id is that + /// provider's own native session id. True for the provider that first created + /// the thread (and for brand-new threads with no map yet); false for a + /// provider the user switched to mid-thread. Decides whether a + /// backend-reported id should rename the thread (owner) or just be recorded + /// as an alternate native id (switched provider). + func providerOwnsThreadId(_ provider: AgentProvider, threadId: String) -> Bool { + let map = providerSessionIdMap(for: threadId) + if map.isEmpty { return true } + return map[provider.rawValue] == threadId + } + + /// The native session id to resume for `provider` on `threadId`, or `nil` to + /// start a fresh native session. A provider that has run here resumes its own + /// recorded id; a provider new to the thread starts fresh; a legacy thread + /// with no map at all resumes on its own id (preserving prior behavior). + func resumeSessionId(for provider: AgentProvider, threadId: String) -> String? { + let map = providerSessionIdMap(for: threadId) + if let id = map[provider.rawValue] { return id } + // No native id recorded for this provider. If the thread has no map at + // all it predates per-provider ids and resumes on its own id; otherwise + // this provider has never run here and must start a fresh session. + return map.isEmpty ? threadId : nil + } + func applySessionIdRedirect(from pendingKey: String, to realSessionId: String) { logger.info("[IDE_SEND_THREAD] session id redirect pendingKey=\(pendingKey, privacy: .public) realSession=\(realSessionId, privacy: .public)") sessionIdRedirect[pendingKey] = realSessionId diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 5600b4d..d2a360b 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -128,6 +128,20 @@ extension AppState { state.model = session.model state.effort = session.effort state.permissionMode = session.permissionMode + // Seed the per-provider native-id map. Legacy threads (created before + // per-provider ids) have no map: the thread id is the persisted + // provider's own native session id, so record and persist that so a + // later provider switch can tell which provider owns the thread id. + var seededProviderIds = threadStore.providerSessionIds(forLocalId: session.id) + if seededProviderIds.isEmpty { + seededProviderIds[session.agentProvider.rawValue] = session.id + threadStore.setProviderSessionId( + localId: session.id, + providerRaw: session.agentProvider.rawValue, + nativeId: session.id + ) + } + state.providerSessionIds = seededProviderIds if let msgs = loadedMessages { state.messages = messagesWithPersistedHookCard(cleanLoadedMessages(msgs), sessionId: session.id) state.planDecisionSummaries = threadStore.loadPlanDecisions(sessionId: session.id) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 1626952..0bd78df 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -55,6 +55,12 @@ struct SessionStreamState { // Per-session overrides (persisted in memory across session switches) var agentProvider: AgentProvider? var model: String? + /// Per-provider native resume ids for this session, keyed by + /// `AgentProvider.rawValue`. Mirrors `ChatThread.providerSessionIds`; seeded + /// on session load and refreshed as each backend reports its native session + /// id. Lets a switched provider resume its own session (or start fresh) + /// instead of resuming another provider's id. + var providerSessionIds: [String: String] = [:] /// Identifies which `ACPClientSpec` to use when `agentProvider == .acp`. var acpClientId: String? var effort: String? @@ -663,12 +669,27 @@ final class AppState { projectDefaultModelSelection(for: project) ?? (selectedAgentProvider, selectedModel) } + func fallbackModel(for provider: AgentProvider, project: Project?) -> String { + if let projectSelection = projectDefaultModelSelection(for: project), + projectSelection.provider == provider { + return projectSelection.model + } + if selectedAgentProvider == provider { + return selectedModel + } + return availableAgentModelSections() + .first { $0.provider == provider }? + .models + .first? + .id ?? selectedModel + } + func effectiveModelSelection(in window: WindowState) -> (provider: AgentProvider, model: String) { - if let provider = window.sessionAgentProvider, let model = window.sessionModel { - return (provider, model) + if let provider = window.sessionAgentProvider { + return (provider, window.sessionModel ?? fallbackModel(for: provider, project: window.selectedProject)) } if let model = window.sessionModel { - return (window.sessionAgentProvider ?? selectedAgentProvider, model) + return (selectedAgentProvider, model) } return defaultModelSelection(for: window.selectedProject) } diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 5baa945..e026295 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -96,6 +96,24 @@ final class ThreadStore { fetch(id: id)?.cliSessionId } + /// Per-provider native session/resume ids recorded for a thread, keyed by + /// `AgentProvider.rawValue`. Empty when the thread has never recorded one. + func providerSessionIds(forLocalId id: String) -> [String: String] { + fetch(id: id)?.providerSessionIds ?? [:] + } + + /// Record a provider's native session id on the thread row, merging into the + /// existing per-provider map. Called when a backend reports its native + /// session id so a later provider switch can resume the right session. + func setProviderSessionId(localId: String, providerRaw: String, nativeId: String) { + guard let row = fetch(id: localId) else { return } + var map = row.providerSessionIds + guard map[providerRaw] != nativeId else { return } + map[providerRaw] = nativeId + row.providerSessionIds = map + save() + } + func fetchThreadSummary(sessionId: String) -> ThreadSummaryRecord? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.sessionId == sessionId } @@ -354,6 +372,9 @@ final class ThreadStore { existingAtNew.title = row.title } if existingAtNew.cliSessionId == nil { existingAtNew.cliSessionId = newId } + if existingAtNew.providerSessionIdsJSON == nil { + existingAtNew.providerSessionIdsJSON = row.providerSessionIdsJSON + } context.delete(row) } else { row.id = newId diff --git a/RxCode/Views/InspectorMemoPanel.swift b/RxCode/Views/InspectorMemoPanel.swift index 604cf8d..8cc0d78 100644 --- a/RxCode/Views/InspectorMemoPanel.swift +++ b/RxCode/Views/InspectorMemoPanel.swift @@ -302,6 +302,15 @@ private struct RichEditorView: NSViewRepresentable { } static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) { + if let textView = nsView.documentView as? MemoTextView { + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + textView.delegate = nil + textView.undoManager?.removeAllActions(withTarget: textView) + textView.allowsUndo = false + nsView.documentView = nil + } coordinator.removeKeyMonitor() coordinator.cancelPendingSave() } diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift index 2dfe3ca..e8343de 100644 --- a/RxCodeTests/AppStateTests.swift +++ b/RxCodeTests/AppStateTests.swift @@ -293,6 +293,17 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(selection.model, "sonnet") } + func testEffectiveModelSelectionHonorsProviderOnlyOverride() { + appState.selectedAgentProvider = .claudeCode + appState.selectedModel = "sonnet" + window.sessionAgentProvider = .codex + + let selection = appState.effectiveModelSelection(in: window) + + XCTAssertEqual(selection.provider, .codex) + XCTAssertEqual(selection.model, AppState.fallbackCodexModels.first) + } + func testACPModelDisplayResolvesClientAndModelOptionNames() { appState.acpClients = [ ACPClientSpec( diff --git a/RxCodeTests/CrossProjectSendConcurrencyTests.swift b/RxCodeTests/CrossProjectSendConcurrencyTests.swift index 3405353..042f6b6 100644 --- a/RxCodeTests/CrossProjectSendConcurrencyTests.swift +++ b/RxCodeTests/CrossProjectSendConcurrencyTests.swift @@ -243,6 +243,108 @@ final class CrossProjectSendConcurrencyTests: XCTestCase { XCTAssertLessThan(elapsed, 0.05, "session-rename waiter took \(elapsed)s — should be near-instant") } + // MARK: - Provider-native session ids + + /// When a user switches providers inside an existing thread, the new provider + /// reports its own native session id. That id must be recorded for resume, but + /// it must not replace the app's local thread id or the foreground window jumps + /// to a state bucket with no messages. + func testSwitchedProviderNativeSessionIdDoesNotReplaceLocalThreadId() async throws { + let project = makeProject("provider-switch") + appState.projects = [project] + + let localThreadId = "claude-thread-\(UUID().uuidString)" + let codexNativeId = "codex-native-\(UUID().uuidString)" + let window = WindowState() + window.selectedProject = project + window.currentSessionId = localThreadId + window.sessionAgentProvider = .codex + window.sessionModel = "gpt-5" + + var state = SessionStreamState() + state.agentProvider = .codex + state.model = "gpt-5" + state.providerSessionIds = [AgentProvider.claudeCode.rawValue: localThreadId] + state.messages = [ChatMessage(role: .user, content: "previous Claude turn")] + appState.sessionStates[localThreadId] = state + + await mockBackend.enqueueScript( + [ + .systemInit(sessionId: codexNativeId, delay: 0.01), + .assistantText("Codex reply", delay: 0.01), + .result(sessionId: codexNativeId, delay: 0.01), + ], + forCwd: project.path + ) + + let maybeStreamId = await appState.sendPrompt("continue with Codex", in: window) + let streamId = try XCTUnwrap(maybeStreamId) + _ = await appState.awaitStreamCompletion(streamId: streamId, timeout: 5, acceptsPartial: false) + + XCTAssertEqual(window.currentSessionId, localThreadId) + XCTAssertFalse(appState.stateForSession(localThreadId).isStreaming) + XCTAssertNil(appState.sessionStates[codexNativeId]) + XCTAssertEqual( + appState.stateForSession(localThreadId).providerSessionIds[AgentProvider.codex.rawValue], + codexNativeId + ) + XCTAssertTrue( + appState.stateForSession(localThreadId).messages.contains { $0.role == .assistant }, + "Assistant reply should remain attached to the local thread" + ) + } + + func testExistingCrossProjectThreadUsesStoredProviderNativeSessionId() async throws { + let project = makeProject("existing-provider-thread") + appState.projects = [project] + + let localThreadId = "local-thread-\(UUID().uuidString)" + let codexNativeId = "codex-native-\(UUID().uuidString)" + let summary = ChatSession.Summary( + id: localThreadId, + projectId: project.id, + title: "Codex thread", + createdAt: Date(), + updatedAt: Date(), + isPinned: false, + agentProvider: .codex, + model: "gpt-5.4", + origin: .codexAppServer + ) + appState.allSessionSummaries = [summary] + appState.threadStore.upsert(summary) + appState.threadStore.setProviderSessionId( + localId: localThreadId, + providerRaw: AgentProvider.codex.rawValue, + nativeId: codexNativeId + ) + + await mockBackend.enqueueScript( + [ + .systemInit(sessionId: codexNativeId, delay: 0.01), + .assistantText("existing Codex reply", delay: 0.01), + .result(sessionId: codexNativeId, delay: 0.01), + ], + forCwd: project.path + ) + + let result = try await appState.sendCrossProject( + projectId: nil, + threadId: localThreadId, + prompt: "continue existing Codex thread", + waitForResponse: true, + timeoutSeconds: 5 + ) + + let requests = await mockBackend.receivedRequests + let request = try XCTUnwrap(requests.last) + XCTAssertEqual(request.sessionId, codexNativeId) + XCTAssertEqual(request.model, "gpt-5.4") + XCTAssertEqual(appState.stateForSession(localThreadId).agentProvider, .codex) + XCTAssertEqual(result.threadId, localThreadId) + XCTAssertTrue(result.done) + } + // MARK: - Helpers private func makeProject(_ name: String) -> Project {