Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5115caa
fix(swift-ios): release accepted commands before optional refreshes
github-actions[bot] Sep 26, 2026
4665771
fix(swift-ios): cancel accepted-command refresh when deleting a thread
github-actions[bot] Sep 27, 2026
462ff3d
fix(swift-ios): keep superseded accepted-send refreshes from publishi…
github-actions[bot] Sep 27, 2026
c4e63af
fix(swift-ios): keep accepted messages visible until their detail arr…
github-actions[bot] Sep 27, 2026
302ea89
fix(swift-ios): release retained messages once confirmed or cleared
github-actions[bot] Sep 27, 2026
b822867
fix(swift-ios): keep accepted messages across eviction and local details
github-actions[bot] Sep 27, 2026
93fe314
fix(swift-ios): keep local message copies in send order
github-actions[bot] Sep 27, 2026
392394d
fix(swift-ios): recover missing completed thread replies
github-actions[bot] Sep 26, 2026
d21341c
fix(swift-ios): hydrate live environments independently
github-actions[bot] Sep 26, 2026
dd9d9ea
fix(swift-ios): keep applying config events after a catalog read fails
github-actions[bot] Sep 27, 2026
e3e7ec1
fix(swift-ios): keep a peer's newer config over an in-flight catalogu…
github-actions[bot] Sep 27, 2026
9cd6d82
fix(swift-ios): stop probing a peer's catalogue once it needs pairing
github-actions[bot] Sep 27, 2026
dca5698
perf(swift-ios): skip validated stale detail replay
github-actions[bot] Sep 8, 2026
8ef7e74
fix(swift-ios): reconcile silent selected-thread streams
github-actions[bot] Sep 26, 2026
4238100
fix(swift-ios): retain selected-thread ownership across navigation
github-actions[bot] Sep 8, 2026
30626ea
fix(swift-ios): finish draft restore when a reopened thread view is d…
github-actions[bot] Sep 27, 2026
d8cf546
fix(swift-ios): stop quiet shell reconciliation after pairing is reje…
github-actions[bot] Sep 27, 2026
c2d2448
fix(swift-ios): back off live peers whose credential is rejected
github-actions[bot] Sep 27, 2026
a082dcc
fix(swift-ios): share a peer's credential rejection with its live stream
github-actions[bot] Sep 27, 2026
9e0fa91
fix(swift-ios): retain Stop feedback until its session is inactive
github-actions[bot] Sep 8, 2026
b709801
docs(swift-ios): explain stop feedback
github-actions[bot] Sep 8, 2026
45266e9
docs(swift-ios): fix paragraph spacing
github-actions[bot] Sep 8, 2026
746d3e3
fix(swift-ios): keep Stop isolated from queued and pending replies
github-actions[bot] Sep 8, 2026
151b303
test(swift-ios): restore late Stop acknowledgement regression
github-actions[bot] Sep 26, 2026
33c35dd
fix(swift-ios): confirm Stop while a live turn advances the shell
github-actions[bot] Sep 27, 2026
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
1,687 changes: 1,321 additions & 366 deletions apps/swift-ios/App/NativeFeatureClient.swift

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions apps/swift-ios/Core/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,29 @@ public actor T3Client {
)
}

/// Passive shells start with a full snapshot on each socket subscription.
/// Registering its identity atomically prevents late socket results from
/// acquiring authority over a replacement connection.
public func shellEventsOnCurrentConnection() async throws -> (
events: AsyncThrowingStream<ShellStreamItem, Error>, connectionID: UUID
) {
try await rpc.subscribeOnCurrentConnection(
RPCMethod.subscribeShell.rawValue,
payload: .object(["requestCompletionMarker": .bool(true)]),
as: ShellStreamItem.self
)
}

public func shellEventBatchesOnCurrentConnection() async throws -> (
events: AsyncThrowingStream<[ShellStreamItem], Error>, connectionID: UUID
) {
try await rpc.subscribeBatchesOnCurrentConnection(
RPCMethod.subscribeShell.rawValue,
payload: .object(["requestCompletionMarker": .bool(true)]),
as: ShellStreamItem.self
)
}

public func threadEventBatches(
threadID: String,
after sequence: Int? = nil,
Expand Down
36 changes: 33 additions & 3 deletions apps/swift-ios/Features/Chat/FeatureComposerView.swift
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ struct FeatureComposerView: View {
private let materializesDefaultSelection: Bool
private let isSending: Bool
private let isWorking: Bool
private let stopPhase: FeatureStopPhase?
private let onRetryStop: (() -> Void)?
@Binding private var focused: Bool
private let contextUsage: Double?
private let forceExpanded: Bool
Expand Down Expand Up @@ -100,6 +102,8 @@ struct FeatureComposerView: View {
materializesDefaultSelection: Bool = true,
isSending: Bool,
isWorking: Bool,
stopPhase: FeatureStopPhase? = nil,
onRetryStop: (() -> Void)? = nil,
focused: Binding<Bool>,
onSend: @escaping () -> Void,
onStop: @escaping () -> Void,
Expand Down Expand Up @@ -141,6 +145,8 @@ struct FeatureComposerView: View {
self.materializesDefaultSelection = materializesDefaultSelection
self.isSending = isSending
self.isWorking = isWorking
self.stopPhase = stopPhase
self.onRetryStop = onRetryStop
_focused = focused
self.onSend = onSend
self.onStop = onStop
Expand Down Expand Up @@ -288,12 +294,28 @@ struct FeatureComposerView: View {

private var composerSurfaceContent: some View {
VStack(spacing: 0) {
if let stopPhase {
HStack {
Text(stopIsUnconfirmed
? "Stop unconfirmed. Waiting for the thread’s status."
: "Stopping… Waiting for the agent to finish.")
if stopPhase == .unconfirmed, let onRetryStop {
Button("Retry stop", action: onRetryStop)
.disabled(!environmentIsConnected)
}
}
.font(T3Typography.supporting)
.foregroundStyle(T3Colors.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.accessibilityIdentifier("thread-stop-status")
}
if let approval = pendingApprovals.first, let onApprovalDecision {
FeatureComposerApprovalPanel(
approval: approval,
position: 1,
total: pendingApprovals.count,
isResponding: resolvingRequestIDs.contains(approval.id),
isResponding: resolvingRequestIDs.contains(approval.id) || stopPhase != nil,
onDecision: { decision in
onApprovalDecision(approval.id, decision)
},
Expand All @@ -310,6 +332,8 @@ struct FeatureComposerView: View {
environmentID: environmentID,
attachmentPreferences: attachmentPreferences
)
.disabled(stopPhase != nil)
.opacity(stopPhase != nil ? 0.56 : 1)
} else if isExpanded {
expandedComposer
} else {
Expand Down Expand Up @@ -724,12 +748,18 @@ struct FeatureComposerView: View {
}

private var submitSymbol: String {
if isSending { return "ellipsis" }
if isSending || (showsStop && stopPhase != nil) { return "ellipsis" }
return showsStop ? "stop.fill" : "arrow.up"
}

private var stopIsUnconfirmed: Bool {
stopPhase != nil && (stopPhase == .unconfirmed || !environmentIsConnected)
}

private var submitAccessibilityLabel: String {
if isSending { return "Sending message" }
if showsStop, stopIsUnconfirmed { return "Stop outcome unconfirmed" }
if showsStop, stopPhase != nil { return "Stopping agent" }
if showsStop { return "Stop agent" }
return isWorking ? "Queue message" : "Send message"
}
Expand All @@ -754,7 +784,7 @@ struct FeatureComposerView: View {
}

private var submitDisabled: Bool {
isSending || (!showsStop && !canSend)
isSending || (showsStop && stopPhase != nil) || (!showsStop && !canSend)
}

private var textIsEmpty: Bool {
Expand Down
49 changes: 34 additions & 15 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct ThreadDetailView: View {
let submitMessage: (FeatureMessageSubmission) async -> Bool
let onNavigateBack: () -> Void
private let draftStore: FeatureComposerDraftStore
private let managesThreadPresentation: Bool

@State private var draft = ""
@State private var composerContext: OrchestrationMessageContext?
Expand Down Expand Up @@ -52,20 +53,22 @@ public struct ThreadDetailView: View {
thread: FeatureThread,
submitMessage: @escaping (FeatureMessageSubmission) async -> Bool,
onNavigateBack: @escaping () -> Void = {},
draftStore: FeatureComposerDraftStore = .shared
draftStore: FeatureComposerDraftStore = .shared,
managesThreadPresentation: Bool = true
) {
self.model = model
self.thread = thread
self.submitMessage = submitMessage
self.onNavigateBack = onNavigateBack
self.draftStore = draftStore
self.managesThreadPresentation = managesThreadPresentation
}

private var threadContent: some View {
Group {
if let detail {
timeline(detail)
} else if isLoading {
} else if isOpening {
FeatureThreadOpeningView()
} else {
ContentUnavailableView {
Expand Down Expand Up @@ -102,16 +105,27 @@ public struct ThreadDetailView: View {
}
}
.task(id: thread.id) {
guard managesThreadPresentation else { return }
isLoading = true
_ = await model.detail(for: thread.id, force: true)
isLoading = false
await model.runThreadPresentation(id: thread.id) {
isLoading = false
}
}
.task(id: thread.id) {
// A cached thread can already show its composer while the server
// is catching up. Local drafts must not wait for that request.
await model.checkRewindRecovery(for: currentThread)
guard !didRestoreDraft else { return }
await restoreDraft(from: composerDraft, key: draftKey)
// When a thread is reopened in the compact split view, SwiftUI can
// report this view as disappeared right after it appears while it
// stays on screen. That cancels this task and it never re-runs, so
// the composer would stay busy. Finish the restore outside the
// view's task lifetime; each thread has its own view identity, so
// a late restore cannot reach another thread's composer.
let restore = Task { @MainActor in
await model.checkRewindRecovery(for: currentThread)
guard !didRestoreDraft else { return }
await restoreDraft(from: composerDraft, key: draftKey)
}
await restore.value
}
.task(id: pullRequestObservationID) {
await observeThreadPullRequest()
Expand All @@ -133,7 +147,7 @@ public struct ThreadDetailView: View {
.onChange(of: threadConnectionState) { _, state in
if state == .connected,
case .failed = model.detailLoadStates[thread.id],
!isLoading {
!isOpening {
reloadThread()
}
}
Expand All @@ -143,7 +157,6 @@ public struct ThreadDetailView: View {
}
}
.onDisappear {
model.releaseThread(thread.id)
persistDraftBeforeLeaving()
}
.sheet(item: $toolSurface) { surface in
Expand Down Expand Up @@ -277,6 +290,12 @@ public struct ThreadDetailView: View {
}
}

private var isOpening: Bool {
if managesThreadPresentation { return isLoading }
if case .failed = model.detailLoadStates[thread.id] { return false }
return detail == nil || model.detailLoadStates[thread.id] == .loading
}

private var detail: FeatureThreadDetail? {
model.details[thread.id]
}
Expand Down Expand Up @@ -683,10 +702,8 @@ public struct ThreadDetailView: View {
}

private func reloadThread() {
isLoading = true
Task {
_ = await model.detail(for: thread.id, force: true, fresh: true)
isLoading = false
if model.refreshThreadPresentation(id: thread.id, onLoaded: { isLoading = false }) {
isLoading = true
}
}

Expand All @@ -699,7 +716,7 @@ public struct ThreadDetailView: View {
ThreadRefreshPresentation.resolve(
loadState: model.detailLoadStates[thread.id],
connectionState: threadConnectionState,
isOpening: isLoading,
isOpening: isOpening,
syncState: model.threadSyncStates[thread.id]
)
}
Expand Down Expand Up @@ -850,10 +867,12 @@ public struct ThreadDetailView: View {
isSending: isSending || isRewinding || !didRestoreDraft,
isWorking: detail.thread.state == .working || detail.thread.state == .queued
|| isCompacting,
stopPhase: model.stopPhase(threadID: thread.id),
onRetryStop: { model.retryCancelTurn(threadID: thread.id) },
focused: $composerFocused,
onSend: send,
onStop: {
Task { await model.cancelTurn(threadID: thread.id) }
model.requestCancelTurn(threadID: thread.id)
},
pendingApprovals: detail.approvals,
pendingUserInputs: detail.userInputs,
Expand Down
Loading
Loading