Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Packages/Sources/RxCodeChatKit/IMETextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) }
Expand Down
25 changes: 25 additions & 0 deletions Packages/Sources/RxCodeCore/Models/ChatThread.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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:))
Expand Down
14 changes: 14 additions & 0 deletions Packages/Sources/RxCodeEditor/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 3 additions & 2 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
52 changes: 41 additions & 11 deletions RxCode/App/AppState+CrossProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,12 @@
// 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
Expand Down Expand Up @@ -325,16 +326,18 @@
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")
Expand All @@ -354,6 +357,17 @@
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.
Expand Down Expand Up @@ -490,6 +504,12 @@
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" {
Expand Down Expand Up @@ -625,7 +645,12 @@
// 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) {
Expand Down Expand Up @@ -674,6 +699,11 @@
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.
Expand Down Expand Up @@ -707,7 +737,7 @@
// `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
)
Expand Down Expand Up @@ -738,7 +768,7 @@
}

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."
Expand All @@ -747,7 +777,7 @@
}

await saveSession(
sessionId: resultEvent.sessionId,
sessionId: sessionKey,
projectId: projectId,
messages: stateForSession(sessionKey).messages
)
Expand Down Expand Up @@ -817,13 +847,13 @@
// 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
)
Expand Down Expand Up @@ -952,4 +982,4 @@
}
}

}

Check warning on line 985 in RxCode/App/AppState+CrossProject.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 768 (file_length)
33 changes: 29 additions & 4 deletions RxCode/App/AppState+CrossProjectSend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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())
}
Expand All @@ -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
}
Expand Down
14 changes: 13 additions & 1 deletion RxCode/App/AppState+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading