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
124 changes: 95 additions & 29 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
private var activeThreadSequence: Int?
private var activeThreadPage: FeatureThreadPage?
private var threadHistoryEpoch = 0
private var detailSnapshotRequiredAfterEpoch: Int?
private var pendingOlderThreadPage: PendingOlderThreadPage?

init(
Expand Down Expand Up @@ -3524,6 +3525,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
guard activeThreadID == threadID,
activeThreadEnvironmentID == client.environment.id else { return }
guard force || detailStreamTask == nil else { return }
if force {
// This required read owns recovery now. An older fallback must not
// replace its loading state with an error from a stale snapshot.
detailCatchUpTask?.cancel()
detailCatchUpTask = nil
detailCatchUpID = nil
continuation.yield(.threadSync(id: threadID, state: .catchingUp))
}
guard detailRefreshTask == nil else {
detailRefreshPending = true
return
Expand All @@ -3534,9 +3543,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let sessionGeneration = environmentGeneration
detailRefreshTask = Task { [weak self] in
do {
// Four updates per second keeps streaming text responsive while
// coalescing bursty shell events into one detail snapshot.
try await Task.sleep(for: .milliseconds(250))
// Shell updates can be coalesced. A required replacement cannot
// apply more thread events until its snapshot arrives.
if !force {
try await Task.sleep(for: .milliseconds(250))
}
} catch {
self?.finishDetailRefresh(generation: generation, client: client)
return
Expand All @@ -3549,7 +3560,20 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
environmentID: client.environment.id,
generation: sessionGeneration
) {
try? await self.refreshThread(id: threadID, client: client)
do {
try await self.refreshThread(id: threadID, client: client)
} catch is CancellationError {
// Closing a thread cancels its read without changing its status.
} catch {
if !Task.isCancelled,
self.detailRefreshGeneration == generation,
self.activeThreadID == threadID,
self.activeRawThread == nil || self.detailStreamTask == nil {
self.continuation.yield(.threadSync(
id: threadID, state: .failed(error.localizedDescription)
))
}
}
}
self.finishDetailRefresh(generation: generation, client: client)
}
Expand All @@ -3572,6 +3596,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let supportsPagination = self?.serverConfigsByEnvironmentID[
route.environmentID
]?.threadSnapshotPagination == true
let subscriptionEpoch = self?.threadHistoryEpoch ?? 0
do {
for try await item in await route.client.threadEvents(
threadID: route.wireID,
Expand All @@ -3582,7 +3607,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
guard !Task.isCancelled, let self,
self.isCurrentDetail(route, generation: streamGeneration),
self.environmentGeneration == sessionGeneration else { return }
self.consumeDetailStreamItem(item, route: route)
self.consumeDetailStreamItem(
item, route: route, subscriptionEpoch: subscriptionEpoch
)
}
} catch is CancellationError {
return
Expand All @@ -3607,7 +3634,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}

private func ensureDetailCatchUpFallback(_ route: NativeThreadRoute, generation: Int) {
guard detailCatchUpTask == nil else { return }
guard detailCatchUpTask == nil, detailRefreshTask == nil else { return }
let id = UUID()
detailCatchUpID = id
let delay = catchUpDelay
Expand All @@ -3628,12 +3655,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
expectedStreamGeneration: generation
)
guard !Task.isCancelled, let self,
self.isCurrentDetail(route, generation: generation) else { return }
self.isCurrentDetail(route, generation: generation),
self.activeRawThread != nil,
!self.detailRefreshPending else { return }
if self.serverConfigsByEnvironmentID[route.environmentID]?
.threadResumeCompletionMarker == true {
self.continuation.yield(.threadSync(id: route.uiID, state: .reconnecting))
} else {
self.continuation.yield(.threadSync(id: route.uiID, state: .live))
self.markDetailSynchronized(route)
}
} catch is CancellationError {
return
Expand All @@ -3646,7 +3675,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}

private func markDetailSynchronized(_ route: NativeThreadRoute) {
guard activeRawThread != nil else { return }
guard activeRawThread != nil, !detailRefreshPending else { return }
// Flush the final message before publishing the completion state.
// Otherwise the loading label can vanish one render before the text.
flushDetailPublish(route)
Expand All @@ -3658,7 +3687,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,

private func consumeDetailStreamItem(
_ item: ThreadStreamItem,
route: NativeThreadRoute
route: NativeThreadRoute,
subscriptionEpoch: Int
) {
switch item {
case .synchronized:
Expand All @@ -3667,21 +3697,41 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
markDetailSynchronized(route)
return
case let .snapshot(snapshot):
guard activeRawThread == nil || snapshot.snapshotSequence > (activeThreadSequence ?? 0) else { return }
// A cursor-less event cannot prove that an already-requested
// snapshot includes it. Keep the post-event read until it does.
if let requiredEpoch = detailSnapshotRequiredAfterEpoch,
subscriptionEpoch < requiredEpoch { return }
guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0),
activeRawThread == nil || snapshot.snapshotSequence > (activeThreadSequence ?? 0) else { return }
resetDetailRefresh()
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
detailSnapshotRequiredAfterEpoch = nil
threadHistoryEpoch &+= 1
pendingOlderThreadPage = nil
activeThreadSequence = snapshot.snapshotSequence
activeRawThread = snapshot.thread
activeThreadPage = featurePage(snapshot.page)
scheduleRawDetailPublish(route: route, mutation: .full)
if detailCompletionReceived { markDetailSynchronized(route) }
case let .event(event):
guard let current = activeRawThread else {
// Do not apply later events to a snapshot that missed earlier
// ones. It must cover every event skipped while replacing it.
if case let .number(value) = event["sequence"],
let sequence = Int(exactly: value), sequence >= 0 {
activeThreadSequence = max(activeThreadSequence ?? 0, sequence)
} else {
// An event without a cursor needs a read started after it.
threadHistoryEpoch &+= 1
detailSnapshotRequiredAfterEpoch = threadHistoryEpoch
pendingOlderThreadPage = nil
}
scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true)
return
}
let reduction = NativeThreadDetailReducer.apply(event, to: current)
if reduction.sequence < 0 {
threadHistoryEpoch &+= 1
detailSnapshotRequiredAfterEpoch = threadHistoryEpoch
pendingOlderThreadPage = nil
activeRawThread = nil
discardPendingDetailPublish()
Expand Down Expand Up @@ -3722,13 +3772,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
detailPublishTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(80))
guard let self else { return }
self.detailPublishTask = nil
guard !Task.isCancelled,
self.detailStreamGeneration == streamGeneration,
self.activeThreadID == route.uiID,
self.activeRawThread != nil else {
return
}
self.detailPublishTask = nil
self.flushDetailPublish(route)
}
}
Expand Down Expand Up @@ -3774,7 +3824,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let needsTrailingRefresh = detailRefreshPending
detailRefreshPending = false
if needsTrailingRefresh, let threadID = activeThreadID {
scheduleDetailRefresh(threadID: threadID, client: client)
// Events received without a base snapshot cannot be reduced. Read
// again even when the stream is open so those events are included.
scheduleDetailRefresh(threadID: threadID, client: client, force: true)
}
}

Expand All @@ -3787,6 +3839,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,

private func resetDetailStream() {
detailStreamGeneration &+= 1
detailSnapshotRequiredAfterEpoch = nil
detailStreamTask?.cancel()
detailStreamTask = nil
detailCatchUpTask?.cancel()
Expand Down Expand Up @@ -4090,6 +4143,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}
let environment = route.client.environment
let generation = environmentGeneration
let historyEpoch = threadHistoryEpoch
let supportsPagination = serverConfigsByEnvironmentID[
environment.id
]?.threadSnapshotPagination == true
Expand All @@ -4104,8 +4158,25 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
throw CancellationError()
}
if activeThreadID == route.uiID {
guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0) else { return }
if activeRawThread == nil, historyEpoch != threadHistoryEpoch {
return
}
guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0) else {
if activeRawThread == nil {
if !detailRefreshPending {
throw NativeFeatureClientError.threadSnapshotOutdated
}
Comment thread
t3dotgg marked this conversation as resolved.
} else if detailCompletionReceived
|| serverConfigsByEnvironmentID[environment.id]?.threadResumeCompletionMarker != true {
markDetailSynchronized(route)
}
return
}
discardPendingDetailPublish()
// This snapshot includes the skipped events, so their pending
// request is satisfied without another HTTP read.
detailRefreshPending = false
detailSnapshotRequiredAfterEpoch = nil
threadHistoryEpoch &+= 1
pendingOlderThreadPage = nil
activeRawThread = snapshot.thread
Expand All @@ -4126,22 +4197,17 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
client: client, thread: snapshot.thread, sequence: snapshot.snapshotSequence,
page: featurePage(snapshot.page)
)
if activeThreadID == route.uiID, detailCompletionReceived {
if activeThreadID == route.uiID,
detailCompletionReceived
|| serverConfigsByEnvironmentID[environment.id]?.threadResumeCompletionMarker != true {
markDetailSynchronized(route)
}
let hydrationBase = latestDetails[route.uiID] ?? detail
let hydrated = await hydratedAttachmentURLs(
in: hydrationBase,
scheduleAttachmentHydration(
in: detail,
threadID: route.uiID,
client: client,
environmentID: environment.id,
generation: generation
environmentID: environment.id
)
guard isKnownClient(client, environmentID: environment.id, generation: generation),
latestDetails[route.uiID] == hydrationBase,
hydrated != hydrationBase else {
return
}
publish(hydrated, threadID: route.uiID, synchronizeRenderedMessages: true)
}

private func emitSnapshot(
Expand Down Expand Up @@ -5956,9 +6022,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
environmentID: String
) {
guard detail.messages.contains(where: { message in
message.attachments.contains {
$0.mimeType.hasPrefix("image/") && $0.url == nil
}
message.attachments.contains { $0.url == nil }
}) else {
return
}
Expand Down Expand Up @@ -7054,6 +7118,7 @@ private enum NativeFeatureClientError: LocalizedError {
case environmentNotFound
case projectNotFound
case threadNotFound
case threadSnapshotOutdated
case workspaceNotFound
case approvalNotFound
case inputRequestNotFound
Expand All @@ -7071,6 +7136,7 @@ private enum NativeFeatureClientError: LocalizedError {
case .environmentNotFound: "That T3 environment is no longer available."
case .projectNotFound: "The selected project is no longer available."
case .threadNotFound: "The selected thread is no longer available."
case .threadSnapshotOutdated: "The computer has not finished updating this thread. Try again."
case .workspaceNotFound: "The thread workspace is no longer available."
case .approvalNotFound: "The approval request is no longer active."
case .inputRequestNotFound: "The input request is no longer active."
Expand Down
36 changes: 23 additions & 13 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,12 @@ public struct ThreadDetailView: View {

Spacer(minLength: 6)

// The per-second timeline only exists for the live working
// duration; idle threads render a static status instead of
// waking every second forever.
// Cached work state is not proof that the agent is still
// running. Only current, working threads need a live timer.
Group {
if currentThread.homeStatus == .working {
if refreshPresentation != nil {
EmptyView()
} else if currentThread.homeStatus == .working {
TimelineView(.periodic(from: .now, by: 1)) { context in
headerStatus(at: context.date)
}
Expand All @@ -331,7 +332,7 @@ public struct ThreadDetailView: View {
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isHeader)
.accessibilityAddTraits(
currentThread.hasLiveWorkingDuration ? .updatesFrequently : []
refreshPresentation == nil && currentThread.hasLiveWorkingDuration ? .updatesFrequently : []
)
.transaction { transaction in
transaction.animation = nil
Expand Down Expand Up @@ -631,17 +632,23 @@ public struct ThreadDetailView: View {
}

private func timeline(_ detail: FeatureThreadDetail) -> some View {
let isWorking = detail.thread.state == .working
let hasActiveWork = detail.thread.state == .working
|| detail.thread.state == .queued
|| detail.thread.state == .monitoring
let isWorking = hasActiveWork && refreshPresentation == nil
return Group {
if detail.messages.isEmpty, !isWorking {
ContentUnavailableView(
"Ready for a task",
systemImage: "sparkles",
description: Text("Tell the agent what you want to build.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if detail.messages.isEmpty, !hasActiveWork {
if refreshPresentation == nil {
ContentUnavailableView(
"Ready for a task",
systemImage: "sparkles",
description: Text("Tell the agent what you want to build.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
FeatureTranscriptCollectionView(
threadID: thread.id,
Expand Down Expand Up @@ -1126,6 +1133,9 @@ enum ThreadRefreshPresentation: Equatable {
}
if isOpening || loadState == .loading { return .loading }
if case .failed = loadState { return .failed }
// A synchronized thread subscription is newer evidence than an
// environment's periodic shell probe. Socket loss has its own state.
if syncState == .live { return nil }
switch connectionState {
case .connecting, .reconnecting: return .reconnecting
case .disconnected: return .offline
Expand Down
4 changes: 3 additions & 1 deletion apps/swift-ios/Features/Root/FeatureRootModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,9 @@ public final class FeatureRootModel {
store(value, delta: delta)
upsert(value.thread)
case let .threadSync(id, state):
threadSyncStates[id] = state
if threadSyncStates[id] != state {
threadSyncStates[id] = state
}
if state == .live, case .failed = detailLoadStates[id] {
detailLoadStates[id] = nil
}
Expand Down
Loading
Loading