Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
68201c5
fix(swift-ios): keep thread receipts and tool history chronological
github-actions[bot] Sep 8, 2026
1eef63c
docs(swift-ios): explain thread receipts
github-actions[bot] Sep 8, 2026
e54dd91
test(ios): await pagination capability before loading the fixture
github-actions[bot] Sep 8, 2026
2e25d10
fix(swift-ios): show last message age on the thread activity row
github-actions[bot] Sep 9, 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
2,080 changes: 1,408 additions & 672 deletions apps/swift-ios/App/NativeFeatureClient.swift

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions apps/swift-ios/Core/NativeTranscriptTimeline.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import Foundation

/// Tool updates may replace only the current row for the same explicit call.
/// Text, notices and other calls close that row; later updates start a continuation.
@MainActor
struct NativeTranscriptTimeline {
var messages: [FeatureMessage] = []
var indexByID: [String: Int] = [:]
var changedIDs: Set<String> = []
private var activityIDs: Set<String> = []
private var tail: (id: String, call: String?, turn: String?, log: NativeWorkLogAccumulator)?

init() {}

init(messages: [FeatureMessage], activities: [OrchestrationActivity], sessionIsLive: Bool) {
self.init()
// Messages have no event sequence. Exact cross-kind timestamp ties are
// ambiguous in snapshots: keep message wire order, then activity sequence.
// Live batches instead append in received event order, including ties.
let sortedActivities = activities.enumerated().map {
(index: $0.offset, date: NativeTimestampParser.parse($0.element.createdAt) ?? .distantPast,
activity: $0.element)
}.sorted { lhs, rhs in
if lhs.date != rhs.date { return lhs.date < rhs.date }
let a = lhs.activity.sequence ?? Int.max
let b = rhs.activity.sequence ?? Int.max
return a != b ? a < b : lhs.index < rhs.index
}
var events: [(date: Date, message: FeatureMessage?, activity: OrchestrationActivity?)] =
messages.map { ($0.createdAt, $0, nil) }
events += sortedActivities.map {
($0.date, nil, $0.activity)
}
for event in events.enumerated().sorted(by: {
$0.element.date != $1.element.date
? $0.element.date < $1.element.date : $0.offset < $1.offset
}) {
if let message = event.element.message { append(message) }
if let activity = event.element.activity { append(activity) }
}
if !sessionIsLive { finishActiveWork() }
}

mutating func append(_ message: FeatureMessage) {
closeTail()
upsert(message)
}

mutating func append(_ activity: OrchestrationActivity) {
guard activityIDs.insert(activity.id).inserted else { return }
let date = NativeTimestampParser.parse(activity.createdAt) ?? .distantPast
if let notice = NativeActivityNotice.message(activity, createdAt: date) {
append(notice)
return
}
let answers = NativeQuestionAnswerHistory.messages(activity, createdAt: date)
if !answers.isEmpty {
for answer in answers { append(answer) }
return
}
guard NativeWorkLogAccumulator.accepts(activity) else { return }
let call = activity.payload["toolCallId"]?.stringValue
?? activity.payload["data"]?["toolCallId"]?.stringValue
let canCoalesce = call?.isEmpty == false && tail?.call == call
&& tail?.turn == activity.turnId && messages.last?.id == tail?.id
&& date >= (messages.last?.createdAt ?? .distantPast)
if !canCoalesce {
closeTail()
tail = ("work-log-\(activity.id)", call, activity.turnId, NativeWorkLogAccumulator())
}
guard var current = tail else { return }
let detail = activity.payload["detail"]?.stringValue
let compact = detail?.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let preview = compact.flatMap { $0.isEmpty ? nil : String($0.prefix(160)) }
current.log.append(activity, preview: preview, createdAt: date)
tail = current
if current.log.hasContent { upsert(current.log.message(groupID: String(current.id.dropFirst(9)))) }
}

mutating func finishActiveWork() { closeTail() }

mutating func rebuildIndexes() {
indexByID = messages.enumerated().reduce(into: [:]) { $0[$1.element.id] = $1.offset }
}

private mutating func closeTail() {
guard var current = tail else { return }
current.log.freeze()
if current.log.hasContent {
upsert(current.log.message(groupID: String(current.id.dropFirst(9))))
}
tail = nil
}

private mutating func upsert(_ message: FeatureMessage) {
changedIDs.insert(message.id)
if let index = indexByID[message.id] {
messages[index] = message
} else if let last = messages.last, last.createdAt > message.createdAt {
messages.append(message)
messages = messages.enumerated().sorted {
$0.element.createdAt != $1.element.createdAt
? $0.element.createdAt < $1.element.createdAt : $0.offset < $1.offset
}.map(\.element)
rebuildIndexes()
} else {
indexByID[message.id] = messages.count
messages.append(message)
}
}
}

struct NativeWorkLogAccumulator {
private static let terminalKinds = Set([
"tool.completed", "task.completed",
])
private static let activeKinds = Set(["tool.started", "tool.updated"])
private static let imageExtensions = Set([
"avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "tif", "tiff", "webp",
])

private(set) var count = 0
private var visibleLines: [String] = []
private var createdAt = Date.distantPast
private var title = "Tool activity"
private var activeEntries: [String: String] = [:]
private var activeOrder: [String] = []
private var imagePaths: [String] = []
private var toolPresentation: ToolActivityPresentation?
private var activePresentations: [String: ToolActivityPresentation] = [:]

var hasActiveWork: Bool { !activeEntries.isEmpty }
var hasContent: Bool { count > 0 || hasActiveWork || !visibleLines.isEmpty || !imagePaths.isEmpty }

static func accepts(_ activity: OrchestrationActivity) -> Bool {
guard activeKinds.contains(activity.kind)
|| (activity.tone != "error" && terminalKinds.contains(activity.kind)) else {
return false
}
if NativeActivityFilters.isPlanBoundaryTool(activity) { return false }
if NativeActivityFilters.isAgentInternal(activity) { return false }
return true
}

mutating func append(
_ activity: OrchestrationActivity,
preview: String?,
createdAt: Date
) {
self.createdAt = createdAt
let key = Self.lifecycleKey(activity)
toolPresentation = ToolActivityPresentation(payload: activity.payload) ?? activePresentations[key]
let label = activity.payload["title"]?.stringValue ?? activity.summary
title = label
let lifecycleStatus = activity.payload["status"]?.stringValue
let isTerminalUpdate = activity.kind == "tool.updated"
&& lifecycleStatus.map { $0 != "inProgress" && $0 != "in_progress" } == true
if Self.activeKinds.contains(activity.kind) && !isTerminalUpdate
&& activity.tone != "error" {
activeEntries[key] = label
activePresentations[key] = toolPresentation
activeOrder.removeAll { $0 == key }
activeOrder.append(key)
} else {
activeEntries[key] = nil
activePresentations[key] = nil
activeOrder.removeAll { $0 == key }
guard activity.tone != "error" else { return }
count += 1
visibleLines.append("• \(preview ?? activity.summary)")
if visibleLines.count > 40 {
visibleLines.removeFirst(visibleLines.count - 40)
}
}
if let path = Self.viewedImagePath(activity), !imagePaths.contains(path) {
imagePaths.append(path)
if imagePaths.count > 8 { imagePaths.removeFirst(imagePaths.count - 8) }
}
}

/// A closed row records its last observation, rather than continuing to look live.
mutating func freeze() {
for key in activeOrder {
if let label = activeEntries[key] { visibleLines.append("• \(label)") }
}
clearActiveWork()
}

mutating func clearActiveWork() {
activeEntries.removeAll(keepingCapacity: true)
activePresentations.removeAll(keepingCapacity: true)
activeOrder.removeAll(keepingCapacity: true)
}

func message(groupID: String) -> FeatureMessage {
var lines: [String] = []
if count > visibleLines.count {
lines.append("\(count - visibleLines.count) earlier updates hidden")
}
lines.append(contentsOf: visibleLines)
var message = FeatureMessage(
id: "work-log-\(groupID)",
role: .tool,
text: lines.joined(separator: "\n"),
createdAt: createdAt,
state: .complete,
toolName: title,
workLogImagePaths: imagePaths.isEmpty ? nil : imagePaths,
activeWorkLabel: activeOrder.last.flatMap { activeEntries[$0] }
)
message.toolPresentation = activeOrder.last.flatMap { activePresentations[$0] } ?? toolPresentation
return message
}

private static func lifecycleKey(_ activity: OrchestrationActivity) -> String {
if let id = activity.payload["toolCallId"]?.stringValue
?? activity.payload["data"]?["toolCallId"]?.stringValue {
return "id:\(id)"
}
let itemType = activity.payload["itemType"]?.stringValue ?? ""
let title = activity.payload["title"]?.stringValue ?? activity.summary
let detail = activity.payload["detail"]?.stringValue ?? ""
return "fallback:\([itemType, title, detail].map(normalizedLifecycleText).joined(separator: "|"))"
}

private static func normalizedLifecycleText(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(
of: #"\s+(complete|completed)$"#,
with: "",
options: .regularExpression
)
}

private static func viewedImagePath(_ activity: OrchestrationActivity) -> String? {
let itemType = normalizedLifecycleText(activity.payload["itemType"]?.stringValue ?? "")
let title = normalizedLifecycleText(activity.payload["title"]?.stringValue ?? activity.summary)
let qualifies = activity.payload["requestKind"]?.stringValue == "file-read"
|| itemType == "image_view"
|| (itemType == "dynamic_tool_call" && title == "read file")
guard qualifies,
let detail = activity.payload["detail"]?.stringValue,
!detail.contains("\n"), !detail.contains("\r") else { return nil }
let path = detail.trimmingCharacters(in: .whitespacesAndNewlines)
guard let ext = path.split(separator: ".").last?.lowercased(),
imageExtensions.contains(String(ext)) else { return nil }
return path
}
}
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
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
Loading
Loading