From 4e104bd2984c035470f60dfbad2f9bf204e73204 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:28:16 +0800 Subject: [PATCH 1/2] fix: improve ide thread send handoff --- .../RxCodeCore/Backend/IDEToolRegistry.swift | 2 +- RxCode/App/AppState+CrossProject.swift | 81 ++++++++++++---- RxCode/App/AppState+CrossProjectSend.swift | 36 +++++++- RxCode/App/AppState+Messaging.swift | 92 ++++++++++++++++++- RxCode/App/AppState.swift | 16 ++++ RxCode/Services/CodexAppServer+Protocol.swift | 8 +- RxCode/Services/CodexAppServer+Turn.swift | 12 +-- .../IDEServer/AppState+IDEToolHandling.swift | 14 ++- RxCode/Services/MCPService.swift | 5 +- 9 files changed, 225 insertions(+), 41 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index 7e8cf02b..dc0b39fd 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -298,7 +298,7 @@ public enum IDEToolRegistry { ]), "timeout_seconds": .object([ "type": .string("number"), - "description": .string("How long to wait for the assistant's reply. Default 120, capped at 600. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."), + "description": .string("How long to wait for the assistant's reply. Default 20, capped at 20. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."), ]), ]), "required": .array([.string("prompt")]), diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 92323047..abc1b040 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -71,6 +71,7 @@ extension AppState { permissionMode: PermissionMode = .default, hookSessionMode: PermissionMode? = nil, projectId: UUID, + includeIDEMCP: Bool = true, window: WindowState ) async { // Mode used when registering a session with PermissionServer for hook auto-approve. @@ -78,7 +79,11 @@ extension AppState { // user's dropdown choice (e.g. `.auto`) should still drive the hook policy. let registerMode = hookSessionMode ?? permissionMode let streamStart = Date() + let debugLogPrefix = streamDebugLogPrefixes[streamId] logger.info("[Stream:UI] starting processStream provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) cli=\(cliSessionId ?? "new", privacy: .public) key=\(internalSessionKey, privacy: .public)") + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=processStreamStart stream=\(streamId) provider=\(agentProvider.rawValue, privacy: .public) cli=\(cliSessionId ?? "new", privacy: .public) key=\(internalSessionKey, privacy: .public)") + } var sessionKey = internalSessionKey @@ -124,6 +129,7 @@ extension AppState { let memoryService = self.memoryService let marketplace = self.marketplace let ideMCPServer = self.ideMCPServer + let shouldIncludeIDEMCP = includeIDEMCP func logPreflight(_ label: String, detail: String = "") { let elapsed = Date().timeIntervalSince(streamStart) @@ -143,12 +149,14 @@ extension AppState { ) : [] async let currentBranchAsync = GitHelper.currentBranch(at: cwd) - async let idePortAsync = ideMCPServer.allocate( - sessionKey: capturedSessionKey, - capabilities: agentProvider.staticCapabilities - ) + async let idePortAsync: UInt16? = shouldIncludeIDEMCP + ? ideMCPServer.allocate( + sessionKey: capturedSessionKey, + capabilities: agentProvider.staticCapabilities + ) + : nil async let skillContextAsync: String? = marketplace.promptContext(for: agentProvider) - async let codexSkillOverridesAsync: [String] = agentProvider == .codex + async let codexSkillOverridesAsync: [String] = shouldIncludeIDEMCP && agentProvider == .codex ? await marketplace.codexConfigOverrides() : [] @@ -186,7 +194,10 @@ extension AppState { // bridge, but they no longer block memory/git/skill resolution. let idePort = await idePortAsync let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } - logPreflight("ideMCP", detail: "port=\(idePort.map(String.init) ?? "")") + logPreflight( + "ideMCP", + detail: "enabled=\(shouldIncludeIDEMCP) port=\(idePort.map(String.init) ?? "")" + ) // Session-start hooks fire once, only for a brand-new thread (no resumed // CLI session). Their stdout is injected into this turn's agent context @@ -219,8 +230,18 @@ extension AppState { logPreflight("skillContext", detail: "chars=0") } case .codex: - mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge) - logPreflight("codexMCP", detail: "args=\(mcpCodexOverrides.count)") + mcpCodexOverrides = await mcp.codexConfigOverrides( + projectPath: cwd, + bridgeCommand: bridge, + disableAllServers: !shouldIncludeIDEMCP + ) + if !shouldIncludeIDEMCP { + mcpCodexOverrides += ["--disable", "plugins"] + } + logPreflight( + "codexMCP", + detail: "args=\(mcpCodexOverrides.count) disabledAll=\(!shouldIncludeIDEMCP)" + ) let codexSkillOverrides = await codexSkillOverridesAsync logPreflight("codexSkillOverrides", detail: "args=\(codexSkillOverrides.count)") mcpCodexOverrides += codexSkillOverrides @@ -285,6 +306,9 @@ extension AppState { } else { let preflightElapsed = Date().timeIntervalSince(streamStart) logger.info("[Stream:UI] backend send starting provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", preflightElapsed), privacy: .public)s cwd=\(cwd, privacy: .public)") + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=backendSendStart stream=\(streamId) after=\(String(format: "%.2f", preflightElapsed), privacy: .public)s provider=\(agentProvider.rawValue, privacy: .public) cwd=\(cwd, privacy: .public)") + } let request = BackendSendRequest( streamId: streamId, prompt: resolvedPrompt, @@ -305,6 +329,9 @@ extension AppState { stream = await backend(for: agentProvider).send(request) let backendReturnedElapsed = Date().timeIntervalSince(streamStart) logger.info("[Stream:UI] backend send returned provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", backendReturnedElapsed), privacy: .public)s") + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=backendSendReturned stream=\(streamId) after=\(String(format: "%.2f", backendReturnedElapsed), privacy: .public)s provider=\(agentProvider.rawValue, privacy: .public)") + } } startFlushTimer(for: sessionKey) @@ -332,6 +359,9 @@ extension AppState { if eventCount == 1 { let totalElapsed = Date().timeIntervalSince(streamStart) logger.info("[Stream:UI] first event arrived provider=\(agentProvider.rawValue, privacy: .public) session=\(sessionKey, privacy: .public) stream=\(streamId) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s awaitGap=\(String(format: "%.2f", gap), privacy: .public)s event=\(Self.streamEventLogName(event), privacy: .public)") + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=firstEvent stream=\(streamId) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s awaitGap=\(String(format: "%.2f", gap), privacy: .public)s event=\(Self.streamEventLogName(event), privacy: .public)") + } } lastEventTime = Date() updateState(sessionKey) { $0.lastStreamEventDate = lastEventTime } @@ -625,6 +655,7 @@ extension AppState { } } } + recordStreamPartialResponseIfNeeded(streamId: streamId, sessionId: sessionKey) case .user(let userMessage): logger.debug("[Stream:UI] event #\(eventCount) .user (gap=\(String(format: "%.1f", gap))s, toolUseId=\(userMessage.toolUseId ?? "none"))") @@ -636,6 +667,10 @@ extension AppState { case .result(let resultEvent): logger.info("[Stream:UI] event #\(eventCount) .result (gap=\(String(format: "%.1f", gap))s, isError=\(resultEvent.isError), session=\(resultEvent.sessionId))") + if let debugLogPrefix { + let totalElapsed = Date().timeIntervalSince(streamStart) + logger.info("\(debugLogPrefix, privacy: .public) phase=resultEvent stream=\(streamId) eventCount=\(eventCount, privacy: .public) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s gap=\(String(format: "%.1f", gap), privacy: .public)s isError=\(resultEvent.isError, privacy: .public) session=\(resultEvent.sessionId, privacy: .public)") + } // With `--input-format stream-json` the CLI stays alive waiting for more // input. Close stdin on `result` so it exits cleanly, then finalize so @@ -716,6 +751,18 @@ extension AppState { } if markUnread { state.hasUncheckedCompletion = true } } + let finalAssistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) + + // Release cross-thread MCP callers as soon as the model turn + // is finalized. Session-end hooks can run follow-up work and + // persist cards afterward, but they must not delay the + // `ide__send_to_thread` JSON-RPC response. + recordStreamCompletion( + streamId: streamId, + sessionId: resultEvent.sessionId, + assistantText: finalAssistantText, + error: resultEvent.isError ? "Agent reported an error result." : nil + ) // Session-stop hooks fire when streaming stops. Before-stop // runs *after* `finalizeStreamSession` so its status card lands @@ -731,7 +778,7 @@ extension AppState { sessionId: resultEvent.sessionId, reason: .completed, turnDidError: resultEvent.isError, - lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), + lastAssistantText: finalAssistantText, hasQueuedFollowups: hasQueuedFollowups )) if stopResult.hasError { @@ -742,13 +789,6 @@ extension AppState { } } - recordStreamCompletion( - streamId: streamId, - sessionId: resultEvent.sessionId, - assistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), - error: resultEvent.isError ? "Agent reported an error result." : nil - ) - if isFg { window.currentSessionId = resultEvent.sessionId if resultEvent.isError { @@ -876,11 +916,15 @@ extension AppState { logger.debug("[Stream:UI] event #\(eventCount) .unknown (gap=\(String(format: "%.1f", gap))s, len=\(raw.count))") } handlePartialEvent(raw, for: sessionKey) + recordStreamPartialResponseIfNeeded(streamId: streamId, sessionId: sessionKey) } } let elapsed = Date().timeIntervalSince(streamStart) logger.info("[Stream:UI] stream ended after \(eventCount) events, \(String(format: "%.1f", elapsed))s total") + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=streamEnded stream=\(streamId) eventCount=\(eventCount, privacy: .public) total=\(String(format: "%.2f", elapsed), privacy: .public)s") + } // Consume any remaining stderr — used as error message content below. // If already consumed at result.isError time, this returns nil. @@ -942,7 +986,7 @@ extension AppState { // already records a completion before reaching here — recordStreamCompletion // is idempotent (it overwrites with the latest), but if a prior call set a // successful completion we don't want to clobber it with an error. - if pendingStreamCompletions[streamId] == nil { + if !recordedStreamCompletionIds.contains(streamId) { let assistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) let errorMsg: String? = eventCount == 0 ? (stderrOutput ?? "Stream ended with no events.") @@ -954,6 +998,9 @@ extension AppState { error: errorMsg ) } + recordedStreamCompletionIds.remove(streamId) + streamPartialResponseDeliveredIds.remove(streamId) + streamDebugLogPrefixes.removeValue(forKey: streamId) } } diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index acfd3754..4dad8ec9 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -1,4 +1,5 @@ import Foundation +import os import RxCodeCore extension AppState { @@ -42,8 +43,11 @@ extension AppState { parentThreadId: String? = nil, threadLabel: String? = nil, skipHooks: Bool = false, - setupKind: String? = nil + setupKind: String? = nil, + includeIDEMCP: Bool = true ) async throws -> CrossProjectSendResult { + logger.info("[IDE_SEND_THREAD] requested projectId=\(projectId?.uuidString ?? "", privacy: .public) threadId=\(threadId ?? "", privacy: .public) wait=\(waitForResponse, privacy: .public) timeout=\(String(format: "%.1f", timeoutSeconds), privacy: .public)s provider=\(agentProvider?.rawValue ?? "", privacy: .public) includeIDEMCP=\(includeIDEMCP, privacy: .public) promptChars=\(prompt.count, privacy: .public)") + // Resolve target project + thread. let resolvedProject: Project let resolvedThreadId: String? @@ -68,6 +72,7 @@ extension AppState { } else { throw CrossProjectSendError.unknownProject(UUID()) } + logger.info("[IDE_SEND_THREAD] target resolved project=\(resolvedProject.id.uuidString, privacy: .public) path=\(resolvedProject.path, privacy: .public) thread=\(resolvedThreadId ?? "", privacy: .public)") // Build a synthetic WindowState. AppState.sessionStates is shared across // windows, so the message + stream are visible to any real window that @@ -94,7 +99,14 @@ extension AppState { } } - guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else { + guard let streamId = await sendPrompt( + prompt, + displayText: prompt, + debugLogPrefix: "[IDE_SEND_THREAD]", + includeIDEMCP: includeIDEMCP, + in: window + ) else { + logger.error("[IDE_SEND_THREAD] sendPrompt failed project=\(resolvedProject.id.uuidString, privacy: .public) requestedThread=\(resolvedThreadId ?? "", privacy: .public)") return CrossProjectSendResult( threadId: resolvedThreadId ?? "", projectId: resolvedProject.id, @@ -103,6 +115,7 @@ extension AppState { error: "Send failed: no session could be allocated." ) } + logger.info("[IDE_SEND_THREAD] stream allocated stream=\(streamId) project=\(resolvedProject.id.uuidString, privacy: .public) initialKey=\(window.currentSessionId ?? "", privacy: .public) requestedThread=\(resolvedThreadId ?? "", privacy: .public)") // After sendPrompt returns, window.currentSessionId is the (possibly // pending-) key the stream is bound to. Resolve the real CLI session @@ -118,12 +131,19 @@ extension AppState { // the caller's deadline; 60s upper bound matches typical first-token // latency under healthy conditions. let renameTimeout = min(max(timeoutSeconds, 1), 60) + logger.info("[IDE_SEND_THREAD] waiting for session rename pendingKey=\(postSendKey, privacy: .public) stream=\(streamId) timeout=\(String(format: "%.1f", renameTimeout), privacy: .public)s") resolvedThreadIdForReturn = await awaitSessionRename( pendingKey: postSendKey, timeout: renameTimeout ) ?? postSendKey + if resolvedThreadIdForReturn == postSendKey { + logger.warning("[IDE_SEND_THREAD] session rename timed out pendingKey=\(postSendKey, privacy: .public) stream=\(streamId)") + } else { + logger.info("[IDE_SEND_THREAD] session rename resolved pendingKey=\(postSendKey, privacy: .public) realThread=\(resolvedThreadIdForReturn, privacy: .public) stream=\(streamId)") + } } else { resolvedThreadIdForReturn = postSendKey + logger.info("[IDE_SEND_THREAD] using existing session key thread=\(resolvedThreadIdForReturn, privacy: .public) stream=\(streamId)") } if let setupKind, resolvedThreadIdForReturn != postSendKey { setupSessionKeys[setupKind, default: []].insert(resolvedThreadIdForReturn) @@ -159,8 +179,13 @@ extension AppState { if !waitForResponse { // Don't leak the result in the dictionary; the caller is // fire-and-forget. Drop it once it lands. + logger.info("[IDE_SEND_THREAD] returning without waiting stream=\(streamId) thread=\(resolvedThreadIdForReturn, privacy: .public)") Task { [weak self] in - _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + _ = await self?.awaitStreamCompletion( + streamId: streamId, + timeout: timeoutSeconds, + acceptsPartial: false + ) } return CrossProjectSendResult( threadId: resolvedThreadIdForReturn, @@ -171,12 +196,14 @@ extension AppState { ) } + logger.info("[IDE_SEND_THREAD] waiting for stream completion stream=\(streamId) thread=\(resolvedThreadIdForReturn, privacy: .public) timeout=\(String(format: "%.1f", timeoutSeconds), privacy: .public)s") let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) if let completion { + logger.info("[IDE_SEND_THREAD] stream completion received stream=\(streamId) session=\(completion.sessionId, privacy: .public) error=\(completion.error ?? "", privacy: .public) assistantChars=\(completion.assistantText.count, privacy: .public)") return CrossProjectSendResult( threadId: completion.sessionId, projectId: resolvedProject.id, - done: completion.error == nil, + done: completion.isFinal && completion.error == nil, assistantText: completion.assistantText, error: completion.error ) @@ -184,6 +211,7 @@ extension AppState { // Timed out. Surface the partial assistant text we have so far so // the caller can decide whether to poll back via get_thread_messages. let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages) + logger.warning("[IDE_SEND_THREAD] stream completion timed out stream=\(streamId) thread=\(resolvedThreadIdForReturn, privacy: .public) currentKey=\(window.currentSessionId ?? "", privacy: .public) partialChars=\(partial.count, privacy: .public)") return CrossProjectSendResult( threadId: resolvedThreadIdForReturn, projectId: resolvedProject.id, diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 00f2e63c..bbadf596 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -215,6 +215,8 @@ extension AppState { initialMessages: [ChatMessage]? = nil, tempFilePaths: [String] = [], isStopHookReprompt: Bool = false, + debugLogPrefix: String? = nil, + includeIDEMCP: Bool = true, in window: WindowState ) async -> UUID? { guard let project = window.selectedProject else { @@ -229,9 +231,15 @@ extension AppState { } let streamId = UUID() + if let debugLogPrefix { + streamDebugLogPrefixes[streamId] = debugLogPrefix + } let isNewSession = window.currentSessionId == nil let isPending = window.currentSessionId.map { window.pendingPlaceholderIds.contains($0) } ?? false let cliSessionId: String? = (isNewSession || isPending) ? nil : 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)") + } if isNewSession { let tempId = "pending-\(streamId.uuidString)" @@ -394,6 +402,9 @@ extension AppState { let selection = effectiveModelSelection(in: window) let effectiveProvider = sessionStates[sessionKey]?.agentProvider ?? selection.provider let effectiveModel = sessionStates[sessionKey]?.model ?? selection.model + if let debugLogPrefix { + logger.info("\(debugLogPrefix, privacy: .public) phase=sendPromptReady stream=\(streamId) sessionKey=\(sessionKey, privacy: .public) provider=\(effectiveProvider.rawValue, privacy: .public) model=\(effectiveModel ?? "", privacy: .public) cwd=\(effectiveCwd, privacy: .public)") + } let task = Task { [weak self, window] in guard let self else { return } @@ -410,6 +421,7 @@ extension AppState { permissionMode: cliPermissionMode, hookSessionMode: hookSessionMode, projectId: project.id, + includeIDEMCP: includeIDEMCP, window: window ) for path in tempFilePaths { @@ -510,45 +522,101 @@ extension AppState { assistantText: String, error: String? ) { + guard recordedStreamCompletionIds.insert(streamId).inserted else { + logger.info("[IDE_SEND_THREAD] ignoring duplicate stream completion stream=\(streamId) session=\(sessionId, privacy: .public)") + return + } + logger.info("[IDE_SEND_THREAD] record stream completion stream=\(streamId) session=\(sessionId, privacy: .public) error=\(error ?? "", privacy: .public) assistantChars=\(assistantText.count, privacy: .public)") let completion = StreamCompletion( sessionId: sessionId, assistantText: assistantText, - error: error + error: error, + isFinal: true ) if let waiter = streamCompletionWaiters.removeValue(forKey: streamId) { + logger.info("[IDE_SEND_THREAD] resuming stream completion waiter stream=\(streamId) session=\(sessionId, privacy: .public)") waiter.resume(with: completion) return } + if streamPartialResponseDeliveredIds.contains(streamId) { + logger.info("[IDE_SEND_THREAD] final stream completion already delivered partially stream=\(streamId) session=\(sessionId, privacy: .public)") + return + } + logger.info("[IDE_SEND_THREAD] parking stream completion stream=\(streamId) session=\(sessionId, privacy: .public)") pendingStreamCompletions[streamId] = completion } + /// Resume an inline `ide__send_to_thread` waiter with the first visible + /// assistant text without marking the target stream finished. The normal + /// `.result` path still runs and records final cleanup later. + func recordStreamPartialResponse( + streamId: UUID, + sessionId: String, + assistantText: String + ) { + let trimmed = assistantText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard let waiter = streamCompletionWaiters[streamId], waiter.acceptsPartial else { return } + streamCompletionWaiters.removeValue(forKey: streamId) + streamPartialResponseDeliveredIds.insert(streamId) + logger.info("[IDE_SEND_THREAD] resuming stream waiter with partial assistant text stream=\(streamId) session=\(sessionId, privacy: .public) assistantChars=\(assistantText.count, privacy: .public)") + waiter.resume(with: StreamCompletion( + sessionId: sessionId, + assistantText: assistantText, + error: nil, + isFinal: false + )) + } + + func recordStreamPartialResponseIfNeeded(streamId: UUID, sessionId: String) { + guard let waiter = streamCompletionWaiters[streamId], waiter.acceptsPartial else { return } + guard let state = sessionStates[sessionId], + !state.textDeltaBuffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } + flushPendingUpdates(for: sessionId, forceText: true) + let assistantText = lastAssistantResponseText(in: stateForSession(sessionId).messages) + recordStreamPartialResponse( + streamId: streamId, + sessionId: sessionId, + assistantText: assistantText + ) + } + /// Wait up to `timeout` seconds for the stream identified by `streamId` /// to record a completion. Event-driven: `recordStreamCompletion` resumes /// the continuation as soon as the result lands, with a parallel /// `Task.sleep(timeout)` to surface `nil` if the deadline passes first. /// Returns the completion if one arrived in time, otherwise `nil`. - func awaitStreamCompletion(streamId: UUID, timeout: TimeInterval) async -> StreamCompletion? { + func awaitStreamCompletion( + streamId: UUID, + timeout: TimeInterval, + acceptsPartial: Bool = true + ) async -> StreamCompletion? { // Fast path — completion already landed before we registered. if let completion = pendingStreamCompletions.removeValue(forKey: streamId) { + logger.info("[IDE_SEND_THREAD] consumed parked stream completion stream=\(streamId) session=\(completion.sessionId, privacy: .public)") return completion } + logger.info("[IDE_SEND_THREAD] registering stream completion waiter stream=\(streamId) timeout=\(String(format: "%.1f", timeout), privacy: .public)s") let timeoutTask = Task { [weak self] in let nanos = UInt64(max(0, timeout) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) guard let self else { return } await MainActor.run { if let waiter = self.streamCompletionWaiters.removeValue(forKey: streamId) { + self.logger.warning("[IDE_SEND_THREAD] stream completion waiter timed out stream=\(streamId)") waiter.resume(with: nil) } } } let result: StreamCompletion? = await withCheckedContinuation { cont in - let waiter = StreamCompletionWaiter(continuation: cont) + let waiter = StreamCompletionWaiter(continuation: cont, acceptsPartial: acceptsPartial) // Re-check between the fast-path read and continuation install — the // recorder may have fired in between if any MainActor work yielded. if let completion = pendingStreamCompletions.removeValue(forKey: streamId) { + logger.info("[IDE_SEND_THREAD] consumed stream completion during waiter install stream=\(streamId) session=\(completion.sessionId, privacy: .public)") waiter.resume(with: completion) } else { streamCompletionWaiters[streamId] = waiter @@ -556,13 +624,17 @@ extension AppState { } timeoutTask.cancel() + logger.info("[IDE_SEND_THREAD] stream completion wait finished stream=\(streamId) resultSession=\(result?.sessionId ?? "", privacy: .public)") return result } /// Discard a recorded completion. Called by long-running `wait_for_response=false` /// MCP sends so the dictionary doesn't grow unbounded with abandoned results. func discardStreamCompletion(streamId: UUID) { + logger.info("[IDE_SEND_THREAD] discarding stream completion stream=\(streamId)") pendingStreamCompletions.removeValue(forKey: streamId) + recordedStreamCompletionIds.remove(streamId) + streamPartialResponseDeliveredIds.remove(streamId) if let waiter = streamCompletionWaiters.removeValue(forKey: streamId) { waiter.resume(with: nil) } @@ -575,8 +647,10 @@ extension AppState { /// resumes any `awaitSessionRename` caller so the cross-project send can /// return the real thread id instead of `pending-…`. 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 if let waiter = sessionIdRenameWaiters.removeValue(forKey: pendingKey) { + logger.info("[IDE_SEND_THREAD] resuming session rename waiter pendingKey=\(pendingKey, privacy: .public) realSession=\(realSessionId, privacy: .public)") waiter.resume(with: realSessionId) } } @@ -586,15 +660,18 @@ extension AppState { /// landed. Returns `nil` on timeout. func awaitSessionRename(pendingKey: String, timeout: TimeInterval) async -> String? { if let real = sessionIdRedirect[pendingKey] { + logger.info("[IDE_SEND_THREAD] consumed existing session redirect pendingKey=\(pendingKey, privacy: .public) realSession=\(real, privacy: .public)") return real } + logger.info("[IDE_SEND_THREAD] registering session rename waiter pendingKey=\(pendingKey, privacy: .public) timeout=\(String(format: "%.1f", timeout), privacy: .public)s") let timeoutTask = Task { [weak self] in let nanos = UInt64(max(0, timeout) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) guard let self else { return } await MainActor.run { if let waiter = self.sessionIdRenameWaiters.removeValue(forKey: pendingKey) { + self.logger.warning("[IDE_SEND_THREAD] session rename waiter timed out pendingKey=\(pendingKey, privacy: .public)") waiter.resume(with: nil) } } @@ -603,6 +680,7 @@ extension AppState { let result: String? = await withCheckedContinuation { cont in let waiter = SessionRenameWaiter(continuation: cont) if let real = sessionIdRedirect[pendingKey] { + logger.info("[IDE_SEND_THREAD] consumed session redirect during waiter install pendingKey=\(pendingKey, privacy: .public) realSession=\(real, privacy: .public)") waiter.resume(with: real) } else { sessionIdRenameWaiters[pendingKey] = waiter @@ -610,6 +688,7 @@ extension AppState { } timeoutTask.cancel() + logger.info("[IDE_SEND_THREAD] session rename wait finished pendingKey=\(pendingKey, privacy: .public) realSession=\(result ?? "", privacy: .public)") return result } @@ -640,9 +719,14 @@ final class SessionRenameWaiter { @MainActor final class StreamCompletionWaiter { private var continuation: CheckedContinuation? + let acceptsPartial: Bool - init(continuation: CheckedContinuation) { + init( + continuation: CheckedContinuation, + acceptsPartial: Bool + ) { self.continuation = continuation + self.acceptsPartial = acceptsPartial } func resume(with value: AppState.StreamCompletion?) { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 64e2619a..72dbcbae 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -219,18 +219,34 @@ final class AppState { let sessionId: String let assistantText: String let error: String? + let isFinal: Bool } /// Completed streams whose owners (callers of `awaitStreamCompletion`) /// have not yet picked up the result. Keyed by `streamId`. var pendingStreamCompletions: [UUID: StreamCompletion] = [:] + /// Streams that have already recorded a completion. This makes completion + /// handoff genuinely idempotent even when the first record resumed a waiter + /// immediately and therefore left no entry in `pendingStreamCompletions`. + var recordedStreamCompletionIds: Set = [] + + /// Streams whose `ide__send_to_thread` waiter was already resumed with + /// first assistant text. Their eventual final completion should not be + /// parked because the MCP caller has already returned. + var streamPartialResponseDeliveredIds: Set = [] + /// Active callers waiting for a stream's completion. Resumed directly by /// `recordStreamCompletion` so the cross-project MCP handler doesn't sit /// in a polling loop on MainActor (which starves the target project's /// `processStream` and freezes its thread until the sender is cancelled). var streamCompletionWaiters: [UUID: StreamCompletionWaiter] = [:] + /// Optional per-stream log prefix for diagnostic flows. Used by + /// `ide__send_to_thread` so target-stream phase logs can be captured with + /// the same predicate as the MCP handoff logs. + var streamDebugLogPrefixes: [UUID: String] = [:] + // MARK: - Session Summaries (shared — lightweight metadata for all projects) var allSessionSummaries: [ChatSession.Summary] = [] diff --git a/RxCode/Services/CodexAppServer+Protocol.swift b/RxCode/Services/CodexAppServer+Protocol.swift index 04f293d0..486c04eb 100644 --- a/RxCode/Services/CodexAppServer+Protocol.swift +++ b/RxCode/Services/CodexAppServer+Protocol.swift @@ -3,7 +3,7 @@ import RxCodeCore import os extension CodexAppServer { - func initializeParams() -> [String: JSONValue] { + nonisolated func initializeParams() -> [String: JSONValue] { [ "clientInfo": .object([ "name": .string("RxCode"), @@ -14,13 +14,13 @@ extension CodexAppServer { ] } - func threadParams(threadId: String?, cwd: String) -> [String: JSONValue] { + nonisolated func threadParams(threadId: String?, cwd: String) -> [String: JSONValue] { var params: [String: JSONValue] = ["cwd": .string(cwd)] if let threadId { params["threadId"] = .string(threadId) } return params } - func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool, ideInstructions: String?) -> [String: JSONValue] { + nonisolated func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool, ideInstructions: String?) -> [String: JSONValue] { var params: [String: JSONValue] = ["cwd": .string(cwd)] if let threadId { params["threadId"] = .string(threadId) } params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) @@ -34,7 +34,7 @@ extension CodexAppServer { return params } - func turnParams(threadId: String, prompt: String, cwd: String, model: String?, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { + nonisolated func turnParams(threadId: String, prompt: String, cwd: String, model: String?, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { var params: [String: JSONValue] = [ "threadId": .string(threadId), "cwd": .string(cwd), diff --git a/RxCode/Services/CodexAppServer+Turn.swift b/RxCode/Services/CodexAppServer+Turn.swift index ad07bbbb..f1f91da9 100644 --- a/RxCode/Services/CodexAppServer+Turn.swift +++ b/RxCode/Services/CodexAppServer+Turn.swift @@ -3,7 +3,7 @@ import RxCodeCore import os extension CodexAppServer { - func runTurn( + nonisolated func runTurn( streamId: UUID, prompt: String, cwd: String, @@ -104,7 +104,7 @@ extension CodexAppServer { default: break } - handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) + await handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) if method == "turn/completed" || method == "turn/failed" { finalUsage = Self.usageInfo(from: object) ?? finalUsage turnCompleted = method == "turn/completed" @@ -133,7 +133,7 @@ extension CodexAppServer { contextWindow: nil ))) } catch { - stderrBuffers[streamId, default: ""] += "\n\(error.localizedDescription)" + await appendStderr("\n\(error.localizedDescription)", streamId: streamId) let sid = threadId ?? "codex-\(streamId.uuidString)" continuation.yield(.result(ResultEvent( durationMs: nil, @@ -145,7 +145,7 @@ extension CodexAppServer { contextWindow: nil ))) } - finalize(streamId: streamId) + await finalize(streamId: streamId) continuation.finish() } @@ -237,7 +237,7 @@ extension CodexAppServer { /// an interactive accept/reject card at the end of a Codex plan-mode turn. Plan body /// is rendered from the latest `update_plan` steps; falls back to the assistant's /// final summary text if no plan steps were emitted. - func emitSynthesizedExitPlanMode( + nonisolated func emitSynthesizedExitPlanMode( planItems: [TodoItem], assistantText: String, continuation: AsyncStream.Continuation @@ -271,7 +271,7 @@ extension CodexAppServer { }.joined(separator: "\n") } - func handleServerRequest( + nonisolated func handleServerRequest( requestId: String, method: String, object: [String: JSONValue], diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index 7fc9fc1c..2d0fb0a6 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -1,4 +1,5 @@ import Foundation +import os import RxCodeCore // MARK: - IDEToolHandling Conformance @@ -333,8 +334,9 @@ extension AppState: IDEToolHandling { let permissionMode: PermissionMode? = arguments["permission_mode"]?.stringValue .flatMap(PermissionMode.init(rawValue:)) let waitForResponse = arguments["wait_for_response"]?.boolValue ?? true - let requestedTimeout = arguments["timeout_seconds"]?.numberValue ?? 120 - let timeoutSeconds = max(1, min(requestedTimeout, 600)) + let requestedTimeout = arguments["timeout_seconds"]?.numberValue ?? 20 + let timeoutSeconds = max(1, min(requestedTimeout, 20)) + logger.info("[IDE_SEND_THREAD] ide__send_to_thread start projectId=\(projectId?.uuidString ?? "", privacy: .public) threadId=\(threadId ?? "", privacy: .public) wait=\(waitForResponse, privacy: .public) requestedTimeout=\(String(format: "%.1f", requestedTimeout), privacy: .public)s effectiveTimeout=\(String(format: "%.1f", timeoutSeconds), privacy: .public)s provider=\(agentProvider?.rawValue ?? "", privacy: .public) model=\(model ?? "", privacy: .public) promptChars=\(prompt.count, privacy: .public)") do { let result = try await sendCrossProject( @@ -346,8 +348,10 @@ extension AppState: IDEToolHandling { effort: effort, permissionMode: permissionMode, waitForResponse: waitForResponse, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + includeIDEMCP: false ) + logger.info("[IDE_SEND_THREAD] ide__send_to_thread result thread=\(result.threadId, privacy: .public) project=\(result.projectId.uuidString, privacy: .public) done=\(result.done, privacy: .public) error=\(result.error ?? "", privacy: .public) assistantChars=\(result.assistantText.count, privacy: .public)") var obj: [String: JSONValue] = [ "thread_id": .string(result.threadId), "project_id": .string(result.projectId.uuidString), @@ -359,7 +363,11 @@ extension AppState: IDEToolHandling { } return jsonTextResult(.object(obj)) } catch let error as CrossProjectSendError { + logger.error("[IDE_SEND_THREAD] ide__send_to_thread failed: \(error.localizedDescription, privacy: .public)") throw IDEToolError.handlerFailed(error.localizedDescription) + } catch { + logger.error("[IDE_SEND_THREAD] ide__send_to_thread failed: \(error.localizedDescription, privacy: .public)") + throw error } } diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index 0391134a..faf03f0b 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -265,7 +265,8 @@ actor MCPService { /// usage, durable memory). func codexConfigOverrides( projectPath: String?, - bridgeCommand: (command: String, args: [String])? = nil + bridgeCommand: (command: String, args: [String])? = nil, + disableAllServers: Bool = false ) async -> [String] { do { let config = try loadConfig() @@ -282,7 +283,7 @@ actor MCPService { // override fails codex's validation ("invalid transport") when // the server isn't already defined in ~/.codex/config.toml. let key = "mcp_servers.\(tomlKey(record.name))" - let enabled = record.isEnabled(for: projectPath) + let enabled = disableAllServers ? false : record.isEnabled(for: projectPath) pairs += ["-c", "\(key)=\(tomlInlineTable(for: record, enabled: enabled))"] } return pairs From 4f23ed789b1d8cb7a3776d34efffe139f1dee159 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:37:37 +0800 Subject: [PATCH 2/2] fix: sidebar should not sync btw workspaces --- Packages/Sources/RxCodeCore/WindowState.swift | 8 +- RxCode/App/AppState+CrossProject.swift | 73 ------------------ RxCode/App/AppState+CrossProjectHelpers.swift | 77 +++++++++++++++++++ RxCode/App/AppState+CrossProjectSend.swift | 6 +- RxCode/App/AppState+Messaging.swift | 10 +-- RxCode/App/AppState+Workspace.swift | 5 ++ RxCode/App/AppState.swift | 10 +++ RxCode/App/Workspace.swift | 10 +++ .../Views/Inspector/RightInspectorPanel.swift | 15 ++-- RxCode/Views/MainView.swift | 5 +- RxCode/Views/ProjectWindowView.swift | 5 +- .../RunProfile/RunProfileToolbarGroup.swift | 3 +- RxCode/Views/Toolbar/RxCodeToolbar.swift | 7 +- 13 files changed, 134 insertions(+), 100 deletions(-) create mode 100644 RxCode/App/AppState+CrossProjectHelpers.swift diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index 47412f6a..d3fd8dd4 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -3,13 +3,11 @@ import SwiftUI // MARK: - AppStorageKeys -/// Keys for app-wide UserDefaults state read directly via `@AppStorage` in views. +/// Raw UserDefaults keys used for persisted UI state. public enum AppStorageKeys { - /// Persisted visibility of the right inspector sidebar. Views read this - /// directly with `@AppStorage` so the panel can be reliably toggled. + /// Persisted visibility of the right inspector sidebar. public static let showRightSidebar = "showRightSidebar" - /// Last visible width of the right inspector sidebar. The panel reads this - /// as its split-view ideal width on the next launch. + /// Last visible width of the right inspector sidebar. public static let rightInspectorWidth = "rightInspectorWidth" } diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index abc1b040..d9bfab78 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -6,58 +6,6 @@ import RxCodeSync import SwiftUI extension AppState { - /// Drop "No response requested." text blocks from the assistant message - /// at `idx`. The marker is the model's response when a turn arrives - /// without a user prompt (ScheduleWakeup, hook re-entry) and reads as - /// noise in the chat UI. - /// - /// `removeIfEmpty` controls whether a message left with no blocks is also - /// deleted. The normal stream path passes `true` to discard pure no-op - /// envelopes; the cancel path passes `false` so pausing a turn never makes - /// the partial assistant bubble disappear. - static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { - guard messages.indices.contains(idx) else { return } - messages[idx].blocks.removeAll { block in - guard let text = block.text else { return false } - return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) - } - if removeIfEmpty, messages[idx].blocks.isEmpty { - messages.remove(at: idx) - } - } - - /// Wrap a branch briefing into a system-prompt section the agent can use as - /// background context. The briefing is auto-generated from earlier threads, - /// so it is framed as advisory rather than authoritative. - static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { - let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - return """ - # Current branch briefing - - The notes below are an accumulated briefing of recent work on this \ - project's current branch (`\(branch)`). They are auto-generated from \ - previous chat threads — treat them as background context for the user's \ - request, and be aware they may be incomplete or slightly out of date. - - \(trimmed) - """ - } - - static func promptWithBackgroundContext(_ contexts: [String], prompt: String) -> String { - let context = contexts - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - guard !context.isEmpty else { return prompt } - return """ - \(context) - - User request: - \(prompt) - """ - } - func processStream( streamId: UUID, prompt: String, @@ -1004,25 +952,4 @@ extension AppState { } } - nonisolated static func streamEventLogName(_ event: StreamEvent) -> String { - switch event { - case .system(let systemEvent): - return "system.\(systemEvent.subtype)" - case .assistant: - return "assistant" - case .user: - return "user" - case .result: - return "result" - case .rateLimitEvent: - return "rateLimitEvent" - case .todoSnapshot: - return "todoSnapshot" - case .acpModelsDiscovered: - return "acpModelsDiscovered" - case .unknown: - return "unknown" - } - } - } diff --git a/RxCode/App/AppState+CrossProjectHelpers.swift b/RxCode/App/AppState+CrossProjectHelpers.swift new file mode 100644 index 00000000..e021de25 --- /dev/null +++ b/RxCode/App/AppState+CrossProjectHelpers.swift @@ -0,0 +1,77 @@ +import Foundation +import RxCodeCore + +extension AppState { + /// Drop "No response requested." text blocks from the assistant message + /// at `idx`. The marker is the model's response when a turn arrives + /// without a user prompt (ScheduleWakeup, hook re-entry) and reads as + /// noise in the chat UI. + /// + /// `removeIfEmpty` controls whether a message left with no blocks is also + /// deleted. The normal stream path passes `true` to discard pure no-op + /// envelopes; the cancel path passes `false` so pausing a turn never makes + /// the partial assistant bubble disappear. + static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { + guard messages.indices.contains(idx) else { return } + messages[idx].blocks.removeAll { block in + guard let text = block.text else { return false } + return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if removeIfEmpty, messages[idx].blocks.isEmpty { + messages.remove(at: idx) + } + } + + /// Wrap a branch briefing into a system-prompt section the agent can use as + /// background context. The briefing is auto-generated from earlier threads, + /// so it is framed as advisory rather than authoritative. + static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { + let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return """ + # Current branch briefing + + The notes below are an accumulated briefing of recent work on this \ + project's current branch (`\(branch)`). They are auto-generated from \ + previous chat threads — treat them as background context for the user's \ + request, and be aware they may be incomplete or slightly out of date. + + \(trimmed) + """ + } + + static func promptWithBackgroundContext(_ contexts: [String], prompt: String) -> String { + let context = contexts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + guard !context.isEmpty else { return prompt } + return """ + \(context) + + User request: + \(prompt) + """ + } + + nonisolated static func streamEventLogName(_ event: StreamEvent) -> String { + switch event { + case .system(let systemEvent): + return "system.\(systemEvent.subtype)" + case .assistant: + return "assistant" + case .user: + return "user" + case .result: + return "result" + case .rateLimitEvent: + return "rateLimitEvent" + case .todoSnapshot: + return "todoSnapshot" + case .acpModelsDiscovered: + return "acpModelsDiscovered" + case .unknown: + return "unknown" + } + } +} diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 4dad8ec9..2f7e9c56 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -197,7 +197,11 @@ extension AppState { } logger.info("[IDE_SEND_THREAD] waiting for stream completion stream=\(streamId) thread=\(resolvedThreadIdForReturn, privacy: .public) timeout=\(String(format: "%.1f", timeoutSeconds), privacy: .public)s") - let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + let completion = await awaitStreamCompletion( + streamId: streamId, + timeout: timeoutSeconds, + acceptsPartial: false + ) if let completion { logger.info("[IDE_SEND_THREAD] stream completion received stream=\(streamId) session=\(completion.sessionId, privacy: .public) error=\(completion.error ?? "", privacy: .public) assistantChars=\(completion.assistantText.count, privacy: .public)") return CrossProjectSendResult( diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index bbadf596..5d33e6ea 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -129,15 +129,11 @@ extension AppState { } func openTerminal(in window: WindowState) async { - // Right sidebar visibility lives in UserDefaults (read via @AppStorage - // in views). Writing here triggers those views to update. - let defaults = UserDefaults.standard - let key = AppStorageKeys.showRightSidebar - if defaults.bool(forKey: key), window.inspectorTab == .terminal { - defaults.set(false, forKey: key) + if showRightSidebar, window.inspectorTab == .terminal { + showRightSidebar = false } else { window.inspectorTab = .terminal - defaults.set(true, forKey: key) + showRightSidebar = true } } diff --git a/RxCode/App/AppState+Workspace.swift b/RxCode/App/AppState+Workspace.swift index 1366f9d4..ee831c71 100644 --- a/RxCode/App/AppState+Workspace.swift +++ b/RxCode/App/AppState+Workspace.swift @@ -68,6 +68,11 @@ extension AppState { notificationsEnabled = workspaceDefaults.bool(for: "notificationsEnabled", default: true) enableAutoCIFix = workspaceDefaults.bool(for: "enableAutoCIFix", default: false) focusMode = workspaceDefaults.bool(for: "focusMode", default: false) + showRightSidebar = workspaceDefaults.bool(for: AppStorageKeys.showRightSidebar, default: false) + rightInspectorWidth = workspaceDefaults.double( + for: AppStorageKeys.rightInspectorWidth, + default: RightInspectorPanelLayout.defaultWidth + ) autoArchiveEnabled = workspaceDefaults.bool(for: "autoArchiveEnabled", default: true) archiveRetentionDays = workspaceDefaults.int(for: "archiveRetentionDays", default: AppState.defaultArchiveRetentionDays) autoDeleteEnabled = workspaceDefaults.bool(for: "autoDeleteEnabled", default: false) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 72dbcbae..1626952d 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -513,6 +513,16 @@ final class AppState { didSet { workspaceDefaults.set(focusMode, for: "focusMode") } } + // MARK: - Inspector Sidebar + + var showRightSidebar: Bool = false { + didSet { workspaceDefaults.set(showRightSidebar, for: AppStorageKeys.showRightSidebar) } + } + + var rightInspectorWidth: Double = RightInspectorPanelLayout.defaultWidth { + didSet { workspaceDefaults.set(rightInspectorWidth, for: AppStorageKeys.rightInspectorWidth) } + } + // MARK: - Archive /// Auto-archive chats whose `updatedAt` is older than this many days. Pinned diff --git a/RxCode/App/Workspace.swift b/RxCode/App/Workspace.swift index 3566f34f..cad4e9ac 100644 --- a/RxCode/App/Workspace.swift +++ b/RxCode/App/Workspace.swift @@ -188,6 +188,16 @@ struct WorkspaceDefaults: Sendable { UserDefaults.standard.set(value, forKey: key(raw)) } + nonisolated func double(for raw: String, default defaultValue: Double) -> Double { + let key = key(raw) + if UserDefaults.standard.object(forKey: key) == nil { return defaultValue } + return UserDefaults.standard.double(forKey: key) + } + + nonisolated func set(_ value: Double, for raw: String) { + UserDefaults.standard.set(value, forKey: key(raw)) + } + nonisolated func data(for raw: String) -> Data? { UserDefaults.standard.data(forKey: key(raw)) } diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 35fa9e11..f823443c 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -19,9 +19,8 @@ struct InspectorTerminal: Identifiable { struct RightInspectorPanel: View { let maxAllowedWidth: CGFloat + @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState - @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false - @AppStorage(AppStorageKeys.rightInspectorWidth) private var rightInspectorWidth = RightInspectorPanelLayout.defaultWidth // Per-thread terminal storage. Each session/thread can have multiple // terminals; all stay alive across thread switches. @@ -38,11 +37,15 @@ struct RightInspectorPanel: View { private var visibleWidth: CGFloat { RightInspectorPanelLayout.restoredWidth( - from: rightInspectorWidth, + from: appState.rightInspectorWidth, maxAllowedWidth: maxAllowedWidth ) } + private var showRightSidebar: Bool { + appState.showRightSidebar + } + private var currentTerminals: [InspectorTerminal] { terminalsBySession[currentSessionKey] ?? [] } @@ -177,7 +180,7 @@ struct RightInspectorPanel: View { .background(terminalShortcuts) .task(id: currentSessionKey) { // Ensure the terminal process exists for this session. The panel's - // visibility is owned by `showRightSidebar` (@AppStorage) — do not + // visibility is owned by the workspace-level AppState — do not // force it open here, or the user could never close it. ensureTerminal(for: currentSessionKey) } @@ -257,7 +260,7 @@ struct RightInspectorPanel: View { } HeaderIconButton(systemImage: "xmark", help: "Close") { - showRightSidebar = false + appState.showRightSidebar = false } .keyboardShortcut("w", modifiers: .command) } @@ -295,7 +298,7 @@ struct RightInspectorPanel: View { .onChanged { value in let startWidth = resizeStartWidth ?? Double(visibleWidth) resizeStartWidth = startWidth - rightInspectorWidth = RightInspectorPanelLayout.resizedWidth( + appState.rightInspectorWidth = RightInspectorPanelLayout.resizedWidth( startWidth: startWidth, leadingEdgeTranslation: value.translation.width, maxAllowedWidth: maxAllowedWidth diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 62843224..535d3b05 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -16,7 +16,6 @@ struct MainView: View { @State private var sidebarTab: SidebarTab = .history @State private var fileSearchTrigger = false @State private var inspectorStarted = false - @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var projectToDelete: Project? = nil @State private var projectToRename: Project? = nil @@ -72,6 +71,10 @@ struct MainView: View { return windowState.selectedProject?.name ?? "" } + private var showRightSidebar: Bool { + appState.showRightSidebar + } + var body: some View { if !appState.onboardingCompleted { OnboardingView() diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index 932e7909..4b5e6e22 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -8,7 +8,6 @@ struct ProjectWindowView: View { @Environment(WindowState.self) private var windowState @State private var inspectorStarted = false @State private var columnVisibility: NavigationSplitViewVisibility = .all - @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false /// Re-runs the new-chat hooks (e.g. the Autopilot `.env` banner) whenever the /// open project or the active session changes — so the banner appears on @@ -29,6 +28,10 @@ struct ProjectWindowView: View { return windowState.selectedProject?.name ?? "" } + private var showRightSidebar: Bool { + appState.showRightSidebar + } + var body: some View { Group { if windowState.isInitialized { diff --git a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift index 69727571..90dc487b 100644 --- a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift +++ b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift @@ -8,7 +8,6 @@ import SwiftUI struct RunProfileToolbarGroup: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState - @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false /// Destinations cached per (container, scheme). Loaded on demand when /// the picker first appears or the refresh button is tapped. @@ -208,7 +207,7 @@ struct RunProfileToolbarGroup: View { private func openRunInspector() { windowState.inspectorMode = .inspector windowState.inspectorTab = .run - showRightSidebar = true + appState.showRightSidebar = true windowState.selectedRunTaskId = appState.runService.activeTasks.first?.id } diff --git a/RxCode/Views/Toolbar/RxCodeToolbar.swift b/RxCode/Views/Toolbar/RxCodeToolbar.swift index c7855543..e0496a8b 100644 --- a/RxCode/Views/Toolbar/RxCodeToolbar.swift +++ b/RxCode/Views/Toolbar/RxCodeToolbar.swift @@ -14,7 +14,6 @@ struct RxCodeToolbarContent: ToolbarContent { @Environment(WindowState.self) private var windowState @Environment(\.openSettings) private var openSettings @Environment(\.openWindow) private var openWindow - @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false private var fileEditCount: Int { _ = appState.threadFileEditsRevision @@ -48,7 +47,7 @@ struct RxCodeToolbarContent: ToolbarContent { } else { windowState.inspectorMode = .inspector windowState.inspectorTab = .terminal - showRightSidebar = true + appState.showRightSidebar = true } } label: { Image(systemName: "apple.terminal") @@ -58,14 +57,14 @@ struct RxCodeToolbarContent: ToolbarContent { Button { windowState.inspectorMode = .inspector windowState.inspectorTab = .memo - showRightSidebar = true + appState.showRightSidebar = true } label: { Image(systemName: "note.text") } .help("Memo") Button { - showRightSidebar.toggle() + appState.showRightSidebar.toggle() } label: { Image(systemName: "sidebar.trailing") .overlay(alignment: .topTrailing) {