Skip to content
Merged
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
32 changes: 26 additions & 6 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,16 @@ jobs:
with:
xcode-version: latest-stable

- name: Install xcpretty
run: gem install xcpretty

- name: Swift version
run: swift --version

- name: Run package tests
working-directory: Packages
run: |
set -o pipefail
swift test 2>&1 | xcpretty
# No xcpretty: it doesn't understand swift-testing's output and
# the non-ASCII record marker (􁁛) crashes xcpretty 0.4.1 on Ruby 4
# with "invalid byte sequence in US-ASCII", which pipefail turns
# into an exit-1 even when every test passed.
run: swift test

test-xcode:
name: xcodebuild test (RxCode)
Expand Down Expand Up @@ -216,3 +215,24 @@ jobs:
name: mobile-ui-test-results-${{ matrix.label }}
path: TestResults-${{ matrix.label }}.xcresult
retention-days: 14

build-android:
name: gradle build (RxCodeAndroid)
runs-on: blacksmith-4vcpu-ubuntu-2404

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Assemble debug APK
working-directory: RxCodeAndroid
run: ./gradlew assembleDebug --stacktrace
15 changes: 13 additions & 2 deletions Packages/Sources/MessageList/MessageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
@State private var isLoadingNext = false
@State private var previousLoadContentHeight: CGFloat?
@State private var nextLoadContentHeight: CGFloat?
@State private var previousLoadCooldownUntil: Date = .distantPast
@State private var nextLoadCooldownUntil: Date = .distantPast

public init(
messages: [Message],
Expand Down Expand Up @@ -467,6 +469,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {

private func triggerLoadPreviousIfNeeded(contentHeight: CGFloat) {
guard !isLoadingPrevious,
Date() >= previousLoadCooldownUntil,
hasMorePrevious(),
let loadMorePrevious,
previousLoadContentHeight != contentHeight
Expand All @@ -475,7 +478,10 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
previousLoadContentHeight = contentHeight
isLoadingPrevious = true
Task { @MainActor in
defer { isLoadingPrevious = false }
defer {
previousLoadCooldownUntil = Date().addingTimeInterval(MessageListConstants.loadMoreCooldownSeconds)
isLoadingPrevious = false
}
do {
try await loadMorePrevious()
} catch {
Expand All @@ -486,6 +492,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {

private func triggerLoadNextIfNeeded(contentHeight: CGFloat) {
guard !isLoadingNext,
Date() >= nextLoadCooldownUntil,
hasMore(),
let loadMore,
nextLoadContentHeight != contentHeight
Expand All @@ -494,7 +501,10 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
nextLoadContentHeight = contentHeight
isLoadingNext = true
Task { @MainActor in
defer { isLoadingNext = false }
defer {
nextLoadCooldownUntil = Date().addingTimeInterval(MessageListConstants.loadMoreCooldownSeconds)
isLoadingNext = false
}
do {
try await loadMore()
} catch {
Expand Down Expand Up @@ -525,6 +535,7 @@ private nonisolated enum MessageListConstants {
static let userScrollDownDelta: CGFloat = 4
static let layoutSettleDelayNanoseconds: UInt64 = 16_000_000
static let streamingBottomScrollInterval: TimeInterval = 2
static let loadMoreCooldownSeconds: TimeInterval = 1
static let scrollAnimationSeconds: Double = 0.24
static let pinAnimationDuration: Duration = .milliseconds(320)
static let pinAnimationSeconds: Double = 0.32
Expand Down
2 changes: 1 addition & 1 deletion Packages/Sources/RxCodeChatKit/TodoProgressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public struct TodoListPopoverView: View {
.padding(12)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
LazyVStack(alignment: .leading, spacing: 6) {
ForEach(todos) { todo in
row(todo)
}
Expand Down
15 changes: 13 additions & 2 deletions Packages/Sources/RxCodeSync/Protocol/Payload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,14 @@ public struct SnapshotPayload: Codable, Sendable {
/// HTTP proxy exposed by the desktop so the mobile in-app browser can open
/// localhost development servers running on the Mac.
public let webProxy: MobileWebProxyInfo?
/// Monotonically-increasing sequence number stamped by the desktop on every
/// outgoing snapshot. Mobile drops snapshots whose `seq` is not strictly
/// greater than the last one it applied, so a delayed snapshot in flight
/// at the moment a fresher one is sent cannot reset
/// `activeSessionMessages` to a stale page and "lose" recently-streamed
/// text. `nil` when the desktop predates snapshot sequencing — mobile
/// applies those as before.
public let seq: UInt64?
public init(
projects: [Project],
sessions: [SessionSummary],
Expand All @@ -359,7 +367,8 @@ public struct SnapshotPayload: Codable, Sendable {
hostMetrics: HostMetricsSnapshot? = nil,
runProfiles: [MobileProjectRunProfiles]? = nil,
runTasks: [MobileRunTaskSnapshot]? = nil,
webProxy: MobileWebProxyInfo? = nil
webProxy: MobileWebProxyInfo? = nil,
seq: UInt64? = nil
) {
self.projects = projects
self.sessions = sessions
Expand All @@ -375,12 +384,13 @@ public struct SnapshotPayload: Codable, Sendable {
self.runProfiles = runProfiles
self.runTasks = runTasks
self.webProxy = webProxy
self.seq = seq
}

private enum CodingKeys: String, CodingKey {
case projects, sessions, branchBriefings, threadSummaries, settings
case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches
case usage, hostMetrics, runProfiles, runTasks, webProxy
case usage, hostMetrics, runProfiles, runTasks, webProxy, seq
}

public init(from decoder: Decoder) throws {
Expand All @@ -399,6 +409,7 @@ public struct SnapshotPayload: Codable, Sendable {
runProfiles = try c.decodeIfPresent([MobileProjectRunProfiles].self, forKey: .runProfiles)
runTasks = try c.decodeIfPresent([MobileRunTaskSnapshot].self, forKey: .runTasks)
webProxy = try c.decodeIfPresent(MobileWebProxyInfo.self, forKey: .webProxy)
seq = try c.decodeIfPresent(UInt64.self, forKey: .seq)
}
}

Expand Down
13 changes: 13 additions & 0 deletions RxCode/App/AppState+CrossProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,18 @@

startFlushTimer(for: sessionKey)

// Reset the watchdog clock at the start so the first event window is
// measured from when we actually began awaiting the stream, not from
// an earlier turn that was finalized on this same session state.
updateState(sessionKey) { $0.lastStreamEventDate = Date() }
let watchdogTask = startStreamWatchdog(
streamId: streamId,
sessionKey: sessionKey,
agentProvider: agentProvider,
in: window
)
defer { watchdogTask.cancel() }

var eventCount = 0
var lastEventTime = Date()

Expand All @@ -389,6 +401,7 @@
eventCount += 1
let gap = Date().timeIntervalSince(lastEventTime)
lastEventTime = Date()
updateState(sessionKey) { $0.lastStreamEventDate = lastEventTime }

guard !Task.isCancelled else {
logger.info("[Stream:UI] task cancelled after \(eventCount) events")
Expand Down Expand Up @@ -1017,4 +1030,4 @@
}
}

}

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

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 800 lines or less excluding comments and whitespaces: currently contains 815 (file_length)
12 changes: 10 additions & 2 deletions RxCode/App/AppState+Messaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,16 @@ extension AppState {

let sessionKey = window.currentSessionId!

// Apply initialMessages if provided
if let initial = initialMessages {
// Apply initialMessages if provided. Refuse to clobber an already-
// populated in-memory list with an empty array — `editAndResend` is
// the only documented caller and always supplies a non-empty truncated
// history, so an empty `initial` would be a regression that erases
// the user's chat.
if let initial = initialMessages, !initial.isEmpty {
updateState(sessionKey) { $0.messages = initial }
} else if let initial = initialMessages, initial.isEmpty {
let existing = sessionStates[sessionKey]?.messages.count ?? 0
logger.error("[sendPrompt] refusing to overwrite \(existing) messages with empty initialMessages for session=\(sessionKey, privacy: .public)")
}

let wasFirstUserMessage = (sessionStates[sessionKey]?.messages.filter { $0.role == .user }.count ?? 0) == 0
Expand Down Expand Up @@ -436,6 +443,7 @@ extension AppState {
state.activeToolInputBuffer = ""
state.textDeltaBuffer = ""
state.pendingToolResults.removeAll()
state.lastStreamEventDate = nil

extraMutations?(&state)

Expand Down
7 changes: 7 additions & 0 deletions RxCode/App/AppState+MobileRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,13 @@ extension AppState {
if sessionStates[summary.id] == nil,
let full = await persistence.loadFullSession(summary: summary, cwd: project.path) {
updateState(summary.id) { state in
// Skip the assignment when persistence returned an empty thread
// (e.g. corrupted JSONL that decoded to no messages). The state
// we just created is also empty, so this is a no-op cosmetic
// guard — but it stops a future caller from accidentally
// overwriting a non-empty in-memory list if this branch is
// ever reached for a session that re-appeared mid-call.
guard !full.messages.isEmpty || state.messages.isEmpty else { return }
state.messages = full.messages
}
}
Expand Down
3 changes: 2 additions & 1 deletion RxCode/App/AppState+MobileSnapshots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@
hostMetrics: hostMetrics,
runProfiles: runProfiles,
runTasks: runTasks,
webProxy: webProxy
webProxy: webProxy,
seq: nextMobileSnapshotSeq()
)
await MobileSyncService.shared.send(.snapshot(payload), toHex: hex)
// The snapshot doesn't carry the question queue; send it alongside so a
Expand Down Expand Up @@ -1052,4 +1053,4 @@
return "Binary file — preview not available."
}
}
}

Check warning on line 1056 in RxCode/App/AppState+MobileSnapshots.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 800 lines or less excluding comments and whitespaces: currently contains 878 (file_length)
6 changes: 5 additions & 1 deletion RxCode/App/AppState+MobileSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,11 @@
do {
switch request.operation {
case .switchExisting:
try await switchToExistingBranch(trimmed, in: window)
// Mobile's new-thread sheet expects each thread to spawn into
// its own worktree — never `git checkout` against the project
// root, since that would silently move main onto the picked
// branch and skip the worktree entirely.
try await attachWorktreeForExistingBranch(trimmed, in: window)
updateMobilePendingWorktree(from: window, projectID: project.id)
case .createNew:
try await attachWorktree(branch: trimmed, in: window)
Expand Down Expand Up @@ -886,4 +890,4 @@
await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex)
}

}

Check warning on line 893 in RxCode/App/AppState+MobileSync.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 800 lines or less excluding comments and whitespaces: currently contains 803 (file_length)
88 changes: 88 additions & 0 deletions RxCode/App/AppState+Stream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,93 @@ import RxCodeSync
import SwiftUI

extension AppState {
// MARK: - Stream Inactivity Watchdog

/// Wall-clock seconds of stream silence after which the watchdog decides
/// the agent is wedged and force-cleans the session. Sized to cover long
/// legitimate gaps — model thinking, a slow `Bash` tool, an LLM-side
/// rate-limit retry — without being so generous that a truly stuck thread
/// stays stuck for the rest of the user's session.
static let streamInactivityTimeout: TimeInterval = 600

/// How often the watchdog wakes up to recompute the gap. Short enough that
/// the unstick fires within a few seconds of the threshold without burning
/// CPU when the stream is healthy.
static let streamInactivityPollInterval: UInt64 = 15 * 1_000_000_000

/// Spawn a background poller that unblocks the session when the agent goes
/// silent without finishing. Without this, a dead/wedged CLI would leave
/// `isStreaming`/`activeStreamId` set forever — every subsequent send into
/// the same thread would no-op silently, which is why users had to start a
/// brand new thread to "fix" a stuck one.
///
/// Self-cancels when the session is no longer streaming, when ownership
/// has moved to another stream, or when no events have been recorded yet.
/// Otherwise on threshold-cross it cancels the backend (so the CLI process
/// gets SIGINT/SIGKILL), cancels the stream task (so `processStream`'s
/// `for await` unblocks), force-finalizes session state, and surfaces an
/// error bubble in the foreground window if the user is looking at it.
@MainActor
func startStreamWatchdog(
streamId: UUID,
sessionKey initialKey: String,
agentProvider: AgentProvider,
in window: WindowState
) -> Task<Void, Never> {
Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: Self.streamInactivityPollInterval)
guard !Task.isCancelled else { return }
guard let self else { return }

// Resolve the live session key — the CLI may have rotated
// session_id mid-stream (compact_boundary, pending → real
// promotion), so the key the watchdog was spawned with may
// have been renamed under it.
let currentKey = self.sessionIdRedirect[initialKey] ?? initialKey
let state = self.stateForSession(currentKey)

guard state.isStreaming, state.activeStreamId == streamId else { return }
guard let last = state.lastStreamEventDate else { continue }

let gap = Date().timeIntervalSince(last)
guard gap >= Self.streamInactivityTimeout else { continue }

self.logger.error("[Stream:Watchdog] no events for \(Int(gap))s on session=\(currentKey, privacy: .public) stream=\(streamId) — force-cleaning")

// Cancel the backend first so the CLI process group gets
// SIGINT/SIGKILL and stops emitting more events into the
// stream we're about to abandon.
await self.backend(for: agentProvider).cancel(streamId: streamId)

self.sessionStates[currentKey]?.streamTask?.cancel()

let message = "Agent stopped responding after \(Int(gap))s of silence. The session was reset — send your message again to continue."

// Inject the error bubble (and trip the unread flag for
// backgrounded sessions) inside the same finalize so the
// mobile diff broadcast carries both at once.
let isForeground = (window.currentSessionId ?? window.newSessionKey) == currentKey
self.finalizeStreamSession(for: currentKey) { state in
if !isForeground { state.hasUncheckedCompletion = true }
state.messages.append(ChatMessage(role: .assistant, content: message, isError: true))
}
if isForeground {
window.errorMessage = message
window.showError = true
}

self.recordStreamCompletion(
streamId: streamId,
sessionId: currentKey,
assistantText: "",
error: message
)
return
}
}
}

// MARK: - Text Delta Grouping

func startFlushTimer(for sessionKey: String) {
Expand Down Expand Up @@ -350,6 +437,7 @@ extension AppState {
state.activeToolInputBuffer = ""
state.textDeltaBuffer = ""
state.pendingToolResults.removeAll()
state.lastStreamEventDate = nil
if let idx = state.messages.indices.reversed().first(where: {
state.messages[$0].role == .assistant && state.messages[$0].isStreaming
}) {
Expand Down
Loading
Loading