Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ describe("ade code remote launcher", () => {
})).rejects.toThrow(/bounded route\/runtime combinations.*deadline/i);

expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(spawnProcess.mock.calls.length).toBeLessThanOrEqual(2);
expect(spawnProcess).toHaveBeenCalled();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expect(children.every((child) => child.kill.mock.calls.length > 0)).toBe(true);
});

Expand Down
101 changes: 94 additions & 7 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2811,6 +2811,11 @@ final class SyncService: ObservableObject {
let startedAt: TimeInterval
}

private struct PendingChatUnsubscribe {
let task: Task<Void, Never>
let scheduledAt: Date
}

private struct PendingOutboundChangeset {
var payload: SyncChangesetBatchPayload
var sentAt: TimeInterval
Expand Down Expand Up @@ -2918,6 +2923,10 @@ final class SyncService: ObservableObject {
private var supportsProjectCatalog = false
private var supportsProjectActions = false
private var supportsChatStreaming = false
private let chatSnapshotRequestCoalescingInterval: TimeInterval = 5
private var recentFullChatSnapshotRequestAtBySession: [String: Date] = [:]
private var pendingChatUnsubscribesBySession: [String: PendingChatUnsubscribe] = [:]
private var chatSubscriptionsNeedingRemoteActivation: Set<String> = []
private var supportsChangesetAck = false
private var relayAuthorizationLease: SyncRelayAuthorizationLease?
private var relayReauthorizationTask: Task<Void, Never>?
Expand Down Expand Up @@ -8333,14 +8342,26 @@ final class SyncService: ObservableObject {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
let wasSubscribed = subscribedChatSessionIds.contains(trimmedSessionId)
if !wasSubscribed {
subscribedChatSessionIds.insert(trimmedSessionId)
localStateRevision += 1
retainChatEventSubscription(sessionId: trimmedSessionId)
let needsRemoteActivation = chatSubscriptionsNeedingRemoteActivation.contains(trimmedSessionId)
let snapshotRequestWasRecentlySent: Bool
if requestSnapshot,
let requestedAt = recentFullChatSnapshotRequestAtBySession[trimmedSessionId],
Date().timeIntervalSince(requestedAt) < chatSnapshotRequestCoalescingInterval {
Comment thread
arul28 marked this conversation as resolved.
Resolved
snapshotRequestWasRecentlySent = true
} else {
snapshotRequestWasRecentlySent = false
}
if canSendLiveRequests() && supportsChatStreaming && (!wasSubscribed || requestSnapshot) {
if canSendLiveRequests()
&& supportsChatStreaming
&& (!wasSubscribed || needsRemoteActivation || requestSnapshot)
&& !snapshotRequestWasRecentlySent {
// Explicit snapshot requests must not advertise a resume point — the
// caller wants the full history, not a delta replay.
let payload = chatSubscriptionPayload(sessionId: trimmedSessionId, maxBytes: maxBytes, includeSinceSeq: !requestSnapshot)
if requestSnapshot {
recentFullChatSnapshotRequestAtBySession[trimmedSessionId] = Date()
}
syncChatLog.notice(
"chat_subscribe_send session=\(trimmedSessionId, privacy: .public) requestSnapshot=\(requestSnapshot, privacy: .public) wasSubscribed=\(wasSubscribed, privacy: .public) maxBytes=\((payload["maxBytes"] as? Int) ?? -1, privacy: .public) sinceSeq=\((payload["sinceSeq"] as? Int) ?? -1, privacy: .public) reducedLoad=\(self.prefersReducedSyncLoad, privacy: .public) state=\(self.connectionState.rawValue, privacy: .public)"
)
Expand All @@ -8349,6 +8370,20 @@ final class SyncService: ObservableObject {
requestId: nil,
payload: payload
)
chatSubscriptionsNeedingRemoteActivation.remove(trimmedSessionId)
}
}

/// Mark a visible chat as locally desired even when the host is unreachable.
/// This synchronously cancels pending warm-cache eviction so reconnect
/// restoration cannot omit a chat that is still on screen.
func retainChatEventSubscription(sessionId: String) {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
if subscribedChatSessionIds.insert(trimmedSessionId).inserted {
chatSubscriptionsNeedingRemoteActivation.insert(trimmedSessionId)
localStateRevision += 1
}
}

Expand All @@ -8359,11 +8394,55 @@ final class SyncService: ObservableObject {
func unsubscribeFromChatEvents(sessionId: String) async throws {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
guard subscribedChatSessionIds.contains(trimmedSessionId) else { return }
subscribedChatSessionIds.remove(trimmedSessionId)
cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
performChatEventUnsubscribe(sessionId: trimmedSessionId)
}

/// Keep a recently-viewed project chat warm briefly so switching back can
/// reuse its render-ready transcript and sequence watermark. Retention is
/// bounded to four chats; older pending subscriptions are released first.
func scheduleChatEventUnsubscribe(
sessionId: String,
delayNanoseconds: UInt64 = 120_000_000_000
) {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty,
subscribedChatSessionIds.contains(trimmedSessionId)
else { return }

cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
while pendingChatUnsubscribesBySession.count >= 4,
let oldestSessionId = pendingChatUnsubscribesBySession.min(by: {
$0.value.scheduledAt < $1.value.scheduledAt
})?.key {
cancelDelayedChatUnsubscribe(sessionId: oldestSessionId)
performChatEventUnsubscribe(sessionId: oldestSessionId)
}

let task = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: delayNanoseconds)
guard let self, !Task.isCancelled else { return }
self.pendingChatUnsubscribesBySession.removeValue(forKey: trimmedSessionId)
self.performChatEventUnsubscribe(sessionId: trimmedSessionId)
}
pendingChatUnsubscribesBySession[trimmedSessionId] = PendingChatUnsubscribe(
task: task,
scheduledAt: Date()
)
}

private func cancelDelayedChatUnsubscribe(sessionId: String) {
pendingChatUnsubscribesBySession.removeValue(forKey: sessionId)?.task.cancel()
}

private func performChatEventUnsubscribe(sessionId: String) {
guard subscribedChatSessionIds.contains(sessionId) else { return }
subscribedChatSessionIds.remove(sessionId)
chatSubscriptionsNeedingRemoteActivation.remove(sessionId)
recentFullChatSnapshotRequestAtBySession.removeValue(forKey: sessionId)
localStateRevision += 1
if canSendLiveRequests() && supportsChatStreaming {
sendEnvelope(type: "chat_unsubscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: trimmedSessionId))
sendEnvelope(type: "chat_unsubscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: sessionId))
}
}

Expand Down Expand Up @@ -14346,6 +14425,7 @@ final class SyncService: ObservableObject {
let dict = payload as? [String: Any],
let snapshot = try? decode(dict, as: SyncChatSubscribeSnapshotPayload.self),
subscribedChatSessionIds.contains(snapshot.sessionId) {
recentFullChatSnapshotRequestAtBySession.removeValue(forKey: snapshot.sessionId)
let resumed = (dict["resumed"] as? Bool) == true
let previousLastSeq = chatEventLastSeqBySession[snapshot.sessionId]
if !resumed {
Expand Down Expand Up @@ -15771,6 +15851,7 @@ final class SyncService: ObservableObject {
requestId: nil,
payload: payload
)
chatSubscriptionsNeedingRemoteActivation.remove(sessionId)
}
}

Expand Down Expand Up @@ -16139,7 +16220,13 @@ final class SyncService: ObservableObject {
// tagging lanes with another project's PRs.
laneGithubPrItems = []
laneGithubPrItemsFetchedAt = nil
for pendingUnsubscribe in pendingChatUnsubscribesBySession.values {
pendingUnsubscribe.task.cancel()
}
pendingChatUnsubscribesBySession.removeAll()
subscribedChatSessionIds.removeAll()
chatSubscriptionsNeedingRemoteActivation.removeAll()
recentFullChatSnapshotRequestAtBySession.removeAll()
// Turn-active hints are scoped to the live connection's event stream —
// a stale "running" hint must not survive a project switch or reconnect.
chatTurnActiveHintBySession.removeAll()
Expand Down
4 changes: 3 additions & 1 deletion apps/ios/ADE/Views/Work/WorkChatSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ struct WorkChatSessionView: View {
@State var latestPinTask: Task<Void, Never>?
@State var latestPinGeneration = 0
@State var assistantPreviewCache = WorkAssistantPreviewCache()
@State var contextUsageViewModelCache = WorkContextUsageViewModelCache()
@State var assistantLineBudgets: [String: Int] = [:]
@State var composerSettingMutationInFlight = false
@State var composerSettingMutationGeneration = 0
Expand Down Expand Up @@ -807,8 +808,9 @@ struct WorkChatSessionView: View {

WorkChatComposerCard(
chatSummary: chatSummaryContext,
usageViewModel: workContextUsageViewModel(
usageViewModel: contextUsageViewModelCache.value(
transcript: transcript,
transcriptRenderSignature: transcriptRenderSignature,
provider: chatSummaryContext.provider,
fallbackContextWindow: chatSummaryContext.contextWindowFallback
),
Expand Down
13 changes: 12 additions & 1 deletion apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1795,7 +1795,18 @@ func pendingWorkInputItemIds(from transcript: [WorkChatEnvelope]) -> Set<String>
}

func sortedWorkChatEnvelopes(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvelope] {
transcript.sorted { lhs, rhs in
guard transcript.count > 1 else { return transcript }
let isAlreadySorted = transcript.indices.dropFirst().allSatisfy { index in
let lhs = transcript[transcript.index(before: index)]
let rhs = transcript[index]
if lhs.timestamp == rhs.timestamp {
return (lhs.sequence ?? 0) <= (rhs.sequence ?? 0)
}
return lhs.timestamp <= rhs.timestamp
}
guard !isAlreadySorted else { return transcript }

return transcript.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp {
return (lhs.sequence ?? 0) < (rhs.sequence ?? 0)
}
Expand Down
16 changes: 4 additions & 12 deletions apps/ios/ADE/Views/Work/WorkRootComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,7 @@ private struct WorkSessionRowRenderSignature: Equatable {
let laneAhead: Int
let laneBehind: Int
let activityTimestamp: String
let previewSource: String?
let previewText: String?
let pinned: Bool
let pullRequestNumber: Int?
let pullRequestState: String?
Expand Down Expand Up @@ -1069,7 +1069,7 @@ private struct WorkSessionRowRenderSignature: Equatable {
self.laneBehind = lane?.status.behind ?? 0
self.activityTimestamp = workSessionActivityTimestamp(session: session, summary: chatSummary)
let canonical = workCanonicalSessionState(session: session, summary: chatSummary)
self.previewSource = workSessionRowPreviewSource(
self.previewText = workSessionRowPreviewSource(
session: session,
chatSummary: chatSummary,
isSettled: canonical.phase == .settled
Expand Down Expand Up @@ -1248,13 +1248,13 @@ struct WorkSessionRow: View, Equatable {
WorkSessionStatusCapsule(badge: badge)
}
Spacer(minLength: 6)
Text(relativeTimestampCompact(workSessionActivityTimestamp(session: session, summary: chatSummary)))
Text(relativeTimestampCompact(renderSignature.activityTimestamp))
.font(.caption2.monospacedDigit())
.foregroundStyle(ADEColor.textMuted)
.lineLimit(1)
}

if let preview = workSessionPreviewText(rowPreviewSource) {
if let preview = renderSignature.previewText {
Text(preview)
.font(.caption2)
.foregroundStyle(ADEColor.textMuted)
Expand Down Expand Up @@ -1387,14 +1387,6 @@ struct WorkSessionRow: View, Equatable {
return workSnoozeWakeLabel(session.snoozedUntil)
}

var rowPreviewSource: String? {
workSessionRowPreviewSource(
session: session,
chatSummary: chatSummary,
isSettled: isSettled
)
}

var isPendingSyncCreation: Bool {
workIsPendingChatCreationSession(session)
}
Expand Down
Loading