Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
f6a6c93
fix(swift-ios): compact consecutive tool groups with relative ages
github-actions[bot] Sep 8, 2026
68d92c2
docs(swift-ios): explain compact tools
github-actions[bot] Sep 8, 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,088 changes: 1,411 additions & 677 deletions apps/swift-ios/App/NativeFeatureClient.swift

Large diffs are not rendered by default.

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

/// Consecutive tool events share a compact group, including distinct calls.
/// Text, notices and turn changes close the group; later events append below them.
@MainActor
struct NativeTranscriptTimeline {
var messages: [FeatureMessage] = []
var indexByID: [String: Int] = [:]
var changedIDs: Set<String> = []
private var activityIDs: Set<String> = []
private var tail: (id: 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 canCoalesce = tail?.turn == activity.turnId && tail != nil
&& messages.last?.id == tail?.id
if !canCoalesce {
closeTail()
tail = ("work-log-\(activity.id)", activity.turnId, NativeWorkLogAccumulator())
}
guard var current = tail else { return }
let detail = activity.payload["detail"]?.stringValue
let preview = detail.flatMap { $0.isEmpty ? nil : $0 }
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 {
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 callOrder: [String] = []
private var callLabels: [String: String] = [:]
private var callDetails: [String: [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 || !callOrder.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 = max(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
if callLabels[key] == nil { callOrder.append(key) }
callLabels[key] = label
if let preview, callDetails[key]?.last != preview {
if callDetails[key, default: []].count == 2 {
callDetails[key]?[1] = preview
} else {
callDetails[key, default: []].append(preview)
}
}
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
}
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() {
clearActiveWork()
}

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

func message(groupID: String) -> FeatureMessage {
let lines = callOrder.map { key in
(["• \(callLabels[key] ?? "Tool activity")"] + (callDetails[key] ?? []))
.joined(separator: "\n")
}
var message = FeatureMessage(
id: "work-log-\(groupID)",
role: .tool,
text: lines.joined(separator: "\n"),
createdAt: createdAt,
state: .complete,
toolName: callOrder.count > 1 ? "\(callOrder.count) tool calls" : title,
workLogImagePaths: imagePaths.isEmpty ? nil : imagePaths,
activeWorkLabel: activeOrder.last.flatMap { activeEntries[$0] }
)
if callOrder.count == 1 {
message.toolPresentation = activeOrder.last.flatMap { activePresentations[$0] } ?? toolPresentation
}
return message
}

private static func lifecycleKey(_ activity: OrchestrationActivity) -> String {
if let id = activity.payload["toolCallId"]?.stringValue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Core/NativeTranscriptTimeline.swift:210

Activities with an empty toolCallId are all assigned the same id: lifecycle key, so parallel calls merge into one callOrder entry and a completion can clear another call's active label. Only use toolCallId as the stable identity when it is non-empty; otherwise use the fallback identity.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Core/NativeTranscriptTimeline.swift around line 210:

Activities with an empty `toolCallId` are all assigned the same `id:` lifecycle key, so parallel calls merge into one `callOrder` entry and a completion can clear another call's active label. Only use `toolCallId` as the stable identity when it is non-empty; otherwise use the fallback identity.

?? 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
}
}

/// Compact elapsed age and its next visible change, shared by all tool labels.
enum NativeToolRelativeAge {
static func text(since date: Date, now: Date) -> String {
guard date > .distantPast, date.timeIntervalSinceReferenceDate.isFinite else { return "—" }
let seconds = max(0, now.timeIntervalSince(date))
if seconds < 10 { return "just now" }
if seconds < 60 { return "\(Int(seconds))s ago" }
if seconds < 3_600 { return "\(Int(seconds / 60))m ago" }
if seconds < 86_400 { return "\(Int(seconds / 3_600))h ago" }
return "\(Int(seconds / 86_400))d ago"
}

static func nextChange(since date: Date, now: Date) -> Date? {
guard date > .distantPast, date.timeIntervalSinceReferenceDate.isFinite else { return nil }
let seconds = max(0, now.timeIntervalSince(date))
if seconds < 10 { return date.addingTimeInterval(10) }
let step: Double = seconds < 60 ? 1 : seconds < 3_600 ? 60 : seconds < 86_400 ? 3_600 : 86_400
return date.addingTimeInterval((floor(seconds / step) + 1) * step)
}
}

/// Preserve already observed event order when adding history or local notices.
enum NativeTranscriptOrder {
static func prependHistory(_ older: [FeatureMessage], to current: [FeatureMessage]) -> [FeatureMessage] {
let loadedIDs = Set(current.map(\.id))
return older.filter { !loadedIDs.contains($0.id) } + current
}

static func insertingFeedback(_ feedback: [FeatureMessage], into current: [FeatureMessage]) -> [FeatureMessage] {
var result = current
for message in feedback {
let index = result.firstIndex { $0.createdAt > message.createdAt } ?? result.endIndex
result.insert(message, at: index)
}
return result
}
}
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
Loading
Loading