-
Notifications
You must be signed in to change notification settings - Fork 6.5k
fix(swift-ios): compact consecutive tool groups with relative ages #10767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
saphid
wants to merge
31
commits into
pingdotgg:t3code/rebuild-mobile-app-swift
from
saphid:pr/swiftui-compact-tools-20260908
Closed
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] 4665771
fix(swift-ios): cancel accepted-command refresh when deleting a thread
github-actions[bot] 462ff3d
fix(swift-ios): keep superseded accepted-send refreshes from publishi…
github-actions[bot] c4e63af
fix(swift-ios): keep accepted messages visible until their detail arr…
github-actions[bot] 302ea89
fix(swift-ios): release retained messages once confirmed or cleared
github-actions[bot] b822867
fix(swift-ios): keep accepted messages across eviction and local details
github-actions[bot] 93fe314
fix(swift-ios): keep local message copies in send order
github-actions[bot] 392394d
fix(swift-ios): recover missing completed thread replies
github-actions[bot] d21341c
fix(swift-ios): hydrate live environments independently
github-actions[bot] dd9d9ea
fix(swift-ios): keep applying config events after a catalog read fails
github-actions[bot] e3e7ec1
fix(swift-ios): keep a peer's newer config over an in-flight catalogu…
github-actions[bot] 9cd6d82
fix(swift-ios): stop probing a peer's catalogue once it needs pairing
github-actions[bot] dca5698
perf(swift-ios): skip validated stale detail replay
github-actions[bot] 8ef7e74
fix(swift-ios): reconcile silent selected-thread streams
github-actions[bot] 4238100
fix(swift-ios): retain selected-thread ownership across navigation
github-actions[bot] 30626ea
fix(swift-ios): finish draft restore when a reopened thread view is d…
github-actions[bot] d8cf546
fix(swift-ios): stop quiet shell reconciliation after pairing is reje…
github-actions[bot] c2d2448
fix(swift-ios): back off live peers whose credential is rejected
github-actions[bot] a082dcc
fix(swift-ios): share a peer's credential rejection with its live stream
github-actions[bot] 9e0fa91
fix(swift-ios): retain Stop feedback until its session is inactive
github-actions[bot] b709801
docs(swift-ios): explain stop feedback
github-actions[bot] 45266e9
docs(swift-ios): fix paragraph spacing
github-actions[bot] 746d3e3
fix(swift-ios): keep Stop isolated from queued and pending replies
github-actions[bot] 151b303
test(swift-ios): restore late Stop acknowledgement regression
github-actions[bot] 33c35dd
fix(swift-ios): confirm Stop while a live turn advances the shell
github-actions[bot] 68201c5
fix(swift-ios): keep thread receipts and tool history chronological
github-actions[bot] 1eef63c
docs(swift-ios): explain thread receipts
github-actions[bot] e54dd91
test(ios): await pagination capability before loading the fixture
github-actions[bot] 2e25d10
fix(swift-ios): show last message age on the thread activity row
github-actions[bot] f6a6c93
fix(swift-ios): compact consecutive tool groups with relative ages
github-actions[bot] 68d92c2
docs(swift-ios): explain compact tools
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ?? 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
Core/NativeTranscriptTimeline.swift:210Activities with an empty
toolCallIdare all assigned the sameid:lifecycle key, so parallel calls merge into onecallOrderentry and a completion can clear another call's active label. Only usetoolCallIdas the stable identity when it is non-empty; otherwise use the fallback identity.🤖 Copy this AI Prompt to have your agent fix this: