From ac7187cd82df82a282da5825a7ed330fb7270f34 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 14:58:56 +1000 Subject: [PATCH 1/4] feat(swift-ios): reveal message timestamps with a horizontal swipe --- .../Features/Chat/ThreadDetailView.swift | 456 +++++++++++++++++- .../TranscriptViewportGeometryTests.swift | 259 +++++++++- 2 files changed, 700 insertions(+), 15 deletions(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 9131c0b41c86..0b82b12f31ba 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1051,6 +1051,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { ) } + static func dismantleUIView(_ collectionView: UICollectionView, coordinator: Coordinator) { + coordinator.disconnect(from: collectionView) + } + private static func makeLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { _, environment in let width = environment.container.effectiveContentSize.width @@ -1077,7 +1081,9 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } @MainActor - final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, UICollectionViewDelegate { + final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, + UICollectionViewDelegate, UIGestureRecognizerDelegate + { private struct MarkdownPrefetch { let revision: MarkdownContentRevision let task: Task @@ -1099,20 +1105,40 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var markdownPrefetches: [String: MarkdownPrefetch] = [:] private var onLoadEarlier: (() -> Void)? private var onDismissKeyboard: (() -> Void)? + private let timestampReveal = FeatureTimestampRevealState() + private weak var collectionView: UICollectionView? + private var visibleTimestampAnchorYs: [String: CGFloat]? + private lazy var timestampPanGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleTimestampPan(_:)) + ) deinit { markdownPrefetches.values.forEach { $0.task.cancel() } } func connect(to collectionView: UICollectionView) { + self.collectionView = collectionView + timestampPanGesture.cancelsTouchesInView = false + timestampPanGesture.maximumNumberOfTouches = 1 + timestampPanGesture.delegate = self + collectionView.addGestureRecognizer(timestampPanGesture) + collectionView.panGestureRecognizer.require(toFail: timestampPanGesture) + let registration = UICollectionView.CellRegistration { [weak self] cell, _, messageID in if messageID == FeatureTranscriptCollectionView.loadEarlierID { + guard let reveal = self?.timestampReveal else { + cell.contentConfiguration = nil + return + } cell.contentConfiguration = UIHostingConfiguration { - FeatureLoadEarlierTurnsButton( - isLoading: self?.currentIsLoadingEarlier == true, - onLoad: { self?.onLoadEarlier?() } - ) + FeatureTimestampRevealViewportRow(reveal: reveal) { + FeatureLoadEarlierTurnsButton( + isLoading: self?.currentIsLoadingEarlier == true, + onLoad: { [weak self] in self?.onLoadEarlier?() } + ) + } } .margins(.all, 0) cell.backgroundConfiguration = UIBackgroundConfiguration.clear() @@ -1120,25 +1146,35 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { return } if messageID == FeatureTranscriptCollectionView.workingIndicatorID { + guard let reveal = self?.timestampReveal else { + cell.contentConfiguration = nil + return + } cell.contentConfiguration = UIHostingConfiguration { - FeatureThreadWorkingIndicator( - activeSubagentCount: self?.currentActiveSubagentCount ?? 0, - backgroundWorkIsActive: self?.currentBackgroundWorkIsActive == true, - isMonitoring: self?.currentIsMonitoring == true - ) + FeatureTimestampRevealViewportRow(reveal: reveal) { + FeatureThreadWorkingIndicator( + activeSubagentCount: self?.currentActiveSubagentCount ?? 0, + backgroundWorkIsActive: self?.currentBackgroundWorkIsActive == true, + isMonitoring: self?.currentIsMonitoring == true + ) + } } .margins(.all, 0) cell.backgroundConfiguration = UIBackgroundConfiguration.clear() cell.accessibilityIdentifier = "thread-working-indicator" return } - guard let message = self?.messagesByID[messageID] else { + guard let self, let message = messagesByID[messageID] else { cell.contentConfiguration = nil return } cell.contentConfiguration = UIHostingConfiguration { - FeatureMessageView(message: message, imageContext: self?.currentImageContext) + FeatureTimestampRevealMessageView( + message: message, + imageContext: self.currentImageContext, + reveal: self.timestampReveal + ) .frame(maxWidth: .infinity, alignment: .leading) } .margins(.all, 0) @@ -1159,6 +1195,87 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { collectionView.delegate = self } + func disconnect(from collectionView: UICollectionView) { + timestampReveal.reset() + visibleTimestampAnchorYs = nil + (collectionView as? BottomAnchoredTranscriptCollectionView)? + .isTimestampRevealActive = false + timestampPanGesture.delegate = nil + collectionView.removeGestureRecognizer(timestampPanGesture) + collectionView.prefetchDataSource = nil + collectionView.delegate = nil + self.collectionView = nil + } + + @objc private func handleTimestampPan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .began: + guard let visibleTimestampAnchorYs else { return } + (collectionView as? BottomAnchoredTranscriptCollectionView)? + .isTimestampRevealActive = true + timestampReveal.begin(anchorYsByMessageID: visibleTimestampAnchorYs) + case .changed: + timestampReveal.update(translationX: gesture.translation(in: gesture.view).x) + case .ended, .cancelled, .failed: + timestampReveal.finish(reduceMotion: UIAccessibility.isReduceMotionEnabled) + visibleTimestampAnchorYs = nil + (collectionView as? BottomAnchoredTranscriptCollectionView)? + .isTimestampRevealActive = false + default: + break + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === timestampPanGesture, + let collectionView = gestureRecognizer.view as? UICollectionView, + let pan = gestureRecognizer as? UIPanGestureRecognizer else { + return true + } + visibleTimestampAnchorYs = nil + let velocity = pan.velocity(in: collectionView) + guard TranscriptTimestampRevealGeometry.shouldBegin( + velocityX: velocity.x, + velocityY: velocity.y + ) else { return false } + + let location = pan.location(in: collectionView) + guard !TranscriptTimestampGestureOwnership.descendantOwnsHorizontalInteraction( + from: collectionView.hitTest(location, with: nil), + host: collectionView + ), + let indexPath = collectionView.indexPathForItem(at: location), + let messageID = dataSource?.itemIdentifier(for: indexPath), + let message = messagesByID[messageID], + FeatureMessageTimestampMetadata.isEligible(message) else { + return false + } + let anchorYs = visibleTimestampAnchors(in: collectionView) + guard !anchorYs.isEmpty else { return false } + visibleTimestampAnchorYs = anchorYs + return true + } + + private func visibleTimestampAnchors( + in collectionView: UICollectionView + ) -> [String: CGFloat] { + var anchors: [String: CGFloat] = [:] + for indexPath in collectionView.indexPathsForVisibleItems { + guard let messageID = dataSource?.itemIdentifier(for: indexPath), + let message = messagesByID[messageID], + let cell = collectionView.cellForItem(at: indexPath), + FeatureMessageTimestampMetadata.isEligible(message), + let anchorY = TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: cell.frame, + viewportBounds: collectionView.bounds + ) else { + continue + } + anchors[messageID] = anchorY + } + return anchors + } + func update( threadID: String, messages: [FeatureMessage], @@ -1180,6 +1297,12 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { self.onDismissKeyboard = onDismissKeyboard let threadChanged = currentThreadID != threadID + if threadChanged { + timestampReveal.reset() + visibleTimestampAnchorYs = nil + (collectionView as? BottomAnchoredTranscriptCollectionView)? + .isTimestampRevealActive = false + } let imageContextChanged = currentImageContext != imageContext let typeSizeChanged = currentDynamicTypeSize != dynamicTypeSize let revisionChanged = currentDetailRevision != renderUpdate?.revision @@ -1242,8 +1365,17 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { messagesByID = replacementMessagesByID } orderedIDs = newIDs - (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = - isInitialLoad || wasNearBottom + timestampReveal.resetIfNoEligibleMessagesRemain(in: messagesByID) + if let anchoredCollectionView = collectionView + as? BottomAnchoredTranscriptCollectionView { + anchoredCollectionView.maintainsBottomAnchor = + TranscriptViewportGeometry.shouldMaintainBottomAnchor( + isInitialLoad: isInitialLoad, + wasNearBottom: wasNearBottom, + isTimestampRevealActive: timestampReveal.isActive, + wasMaintainingBottomAnchor: anchoredCollectionView.maintainsBottomAnchor + ) + } var snapshot: NSDiffableDataSourceSnapshot if threadChanged || loadEarlierChanged { @@ -1302,6 +1434,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { [weak self, weak collectionView] in guard let self, let collectionView else { return } DispatchQueue.main.async { + if self.timestampReveal.isActive { + if let prependAnchor { + self.restore( + prependAnchor, + in: collectionView, + dataSource: dataSource + ) + } + collectionView.layoutIfNeeded() + self.timestampReveal.refresh( + anchorYsByMessageID: self.visibleTimestampAnchors( + in: collectionView + ) + ) + return + } if shouldFollowBottom { self.scrollToBottom( collectionView, @@ -1547,6 +1695,254 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } } +enum TranscriptTimestampRevealGeometry { + static let maximumWidth: CGFloat = 76 + static let minimumAnchorInset: CGFloat = 18 + private static let horizontalDominance: CGFloat = 1.25 + + static func shouldBegin(velocityX: CGFloat, velocityY: CGFloat) -> Bool { + velocityX < 0 && abs(velocityX) > abs(velocityY) * horizontalDominance + } + + static func width(translationX: CGFloat) -> CGFloat { + min(maximumWidth, max(0, -translationX)) + } + + static func anchorCenterY(requestedY: CGFloat, rowHeight: CGFloat) -> CGFloat { + guard rowHeight > minimumAnchorInset * 2 else { + return max(0, rowHeight / 2) + } + return min(rowHeight - minimumAnchorInset, max(minimumAnchorInset, requestedY)) + } + + static func resolvedAnchorCenterY( + requestedY: CGFloat?, + rowHeight: CGFloat + ) -> CGFloat { + guard let requestedY else { + return anchorCenterY(requestedY: rowHeight / 2, rowHeight: rowHeight) + } + return min(max(0, rowHeight), max(0, requestedY)) + } + + static func visibleAnchorCenterY( + rowFrame: CGRect, + viewportBounds: CGRect + ) -> CGFloat? { + let visibleFrame = rowFrame.intersection(viewportBounds) + guard !visibleFrame.isEmpty else { return nil } + let visibleMinimumY = visibleFrame.minY - rowFrame.minY + let visibleMaximumY = visibleFrame.maxY - rowFrame.minY + guard visibleFrame.height > minimumAnchorInset * 2 else { + return (visibleMinimumY + visibleMaximumY) / 2 + } + return min( + visibleMaximumY - minimumAnchorInset, + max(visibleMinimumY + minimumAnchorInset, visibleFrame.midY - rowFrame.minY) + ) + } +} + +enum TranscriptTimestampGestureOwnership { + @MainActor + static func descendantOwnsHorizontalInteraction( + from touchedView: UIView?, + host: UIView + ) -> Bool { + var candidate = touchedView + while let view = candidate, view !== host { + if let scrollView = view as? UIScrollView, + !(scrollView is UITextView), + (scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width > scrollView.bounds.width + 1) { + return true + } + candidate = view.superview + } + return false + } +} + +struct TranscriptTimestampRevealModel: Equatable { + private(set) var width: CGFloat = 0 + private(set) var anchorYsByMessageID: [String: CGFloat] = [:] + + var isActive: Bool { + !anchorYsByMessageID.isEmpty + } + + mutating func begin(anchorYsByMessageID: [String: CGFloat]) { + guard !anchorYsByMessageID.isEmpty else { return } + self.anchorYsByMessageID = anchorYsByMessageID + width = 0 + } + + mutating func update(translationX: CGFloat) { + guard isActive else { return } + width = TranscriptTimestampRevealGeometry.width(translationX: translationX) + } + + mutating func refresh(anchorYsByMessageID: [String: CGFloat]) { + guard isActive, !anchorYsByMessageID.isEmpty else { return } + self.anchorYsByMessageID = anchorYsByMessageID + } + + mutating func finish() { + width = 0 + anchorYsByMessageID = [:] + } + + func anchorY(for messageID: String) -> CGFloat? { + anchorYsByMessageID[messageID] + } + + func hasAnchor(where predicate: (String) -> Bool) -> Bool { + anchorYsByMessageID.keys.contains(where: predicate) + } +} + +enum FeatureMessageTimestampMetadata { + static func isEligible(_ message: FeatureMessage) -> Bool { + isEligible( + role: message.role, + state: message.state, + createdAt: message.createdAt + ) + } + + static func isEligible( + role: FeatureMessageRole, + state: FeatureMessageState, + createdAt: Date + ) -> Bool { + guard state == .complete, createdAt != .distantPast else { return false } + return role == .user || role == .assistant + } + + static func accessibilityLabel(for role: FeatureMessageRole) -> String? { + switch role { + case .user: "Sent" + case .assistant: "Received" + case .tool, .system: nil + } + } +} + +@MainActor +private final class FeatureTimestampRevealState: ObservableObject { + @Published private var model = TranscriptTimestampRevealModel() + + var width: CGFloat { + model.width + } + + var isActive: Bool { + model.isActive + } + + func anchorY(for messageID: String) -> CGFloat? { + model.anchorY(for: messageID) + } + + func begin(anchorYsByMessageID: [String: CGFloat]) { + updateWithoutAnimation { $0.begin(anchorYsByMessageID: anchorYsByMessageID) } + } + + func update(translationX: CGFloat) { + updateWithoutAnimation { $0.update(translationX: translationX) } + } + + func refresh(anchorYsByMessageID: [String: CGFloat]) { + updateWithoutAnimation { $0.refresh(anchorYsByMessageID: anchorYsByMessageID) } + } + + func finish(reduceMotion: Bool) { + var finished = model + finished.finish() + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { + model = finished + } + } + + func reset() { + updateWithoutAnimation { $0.finish() } + } + + func resetIfNoEligibleMessagesRemain(in messagesByID: [String: FeatureMessage]) { + guard model.isActive else { return } + let hasEligibleMessage = model.hasAnchor { messageID in + messagesByID[messageID].map(FeatureMessageTimestampMetadata.isEligible) == true + } + if !hasEligibleMessage { reset() } + } + + private func updateWithoutAnimation( + _ update: (inout TranscriptTimestampRevealModel) -> Void + ) { + var next = model + update(&next) + guard next != model else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + model = next + } + } +} + +private struct FeatureTimestampRevealMessageView: View { + let message: FeatureMessage + let imageContext: MarkdownImageContext? + @ObservedObject var reveal: FeatureTimestampRevealState + + var body: some View { + let revealWidth = reveal.width + if FeatureMessageTimestampMetadata.isEligible(message) { + let requestedAnchorY = reveal.anchorY(for: message.id) + FeatureMessageView(message: message, imageContext: imageContext) + .overlay { + GeometryReader { geometry in + Text(message.createdAt, format: .dateTime.hour().minute()) + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .dynamicTypeSize(.small ... .accessibility1) + .frame( + width: TranscriptTimestampRevealGeometry.maximumWidth, + alignment: .trailing + ) + .position( + x: geometry.size.width + + TranscriptTimestampRevealGeometry.maximumWidth / 2, + y: TranscriptTimestampRevealGeometry.resolvedAnchorCenterY( + requestedY: requestedAnchorY, + rowHeight: geometry.size.height + ) + ) + .opacity(revealWidth > 0 ? 1 : 0) + .accessibilityHidden(true) + } + .allowsHitTesting(false) + } + .offset(x: -revealWidth) + } else { + FeatureMessageView(message: message, imageContext: imageContext) + .offset(x: -revealWidth) + } + } +} + +private struct FeatureTimestampRevealViewportRow: View { + @ObservedObject var reveal: FeatureTimestampRevealState + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .offset(x: -reveal.width) + } +} + private struct FeatureLoadEarlierTurnsButton: View { let isLoading: Bool let onLoad: () -> Void @@ -1628,6 +2024,16 @@ struct TranscriptViewportGeometry: Equatable { max(-topInset, contentHeight - viewportHeight + bottomInset) } + static func shouldMaintainBottomAnchor( + isInitialLoad: Bool, + wasNearBottom: Bool, + isTimestampRevealActive: Bool, + wasMaintainingBottomAnchor: Bool + ) -> Bool { + isInitialLoad || wasNearBottom + || (isTimestampRevealActive && wasMaintainingBottomAnchor) + } + func restoredBottomOffset( after previous: Self?, maintainsBottomAnchor: Bool, @@ -1891,6 +2297,7 @@ private struct ThreadBackSwipeGestureView: UIViewRepresentable { /// Preserve the visual bottom only while the reader is already following the latest turn. private final class BottomAnchoredTranscriptCollectionView: UICollectionView { var maintainsBottomAnchor = false + var isTimestampRevealActive = false private var lastLaidOutGeometry: TranscriptViewportGeometry? private var isRestoringBottomAnchor = false @@ -1910,6 +2317,7 @@ private final class BottomAnchoredTranscriptCollectionView: UICollectionView { after: lastLaidOutGeometry, maintainsBottomAnchor: maintainsBottomAnchor, isInteracting: isDragging || isDecelerating || isRestoringBottomAnchor + || isTimestampRevealActive ) else { return } @@ -2138,6 +2546,7 @@ struct FeatureMessageView: View { .accessibilityLabel("You") .accessibilityValue(accessibilityValue) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .assistant: VStack(alignment: .leading, spacing: 10) { if message.state == .streaming { @@ -2160,6 +2569,7 @@ struct FeatureMessageView: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .tool: FeatureWorkLogView(message: message) .id(message.id) @@ -2228,6 +2638,24 @@ private struct FeatureWorkLogView: View { } } +private struct FeatureMessageTimestampAccessibilityModifier: ViewModifier { + let message: FeatureMessage + + @ViewBuilder + func body(content: Content) -> some View { + if FeatureMessageTimestampMetadata.isEligible(message), + let label = FeatureMessageTimestampMetadata.accessibilityLabel(for: message.role) { + content.accessibilityCustomContent( + Text(label), + Text(message.createdAt.formatted(date: .omitted, time: .shortened)), + importance: .high + ) + } else { + content + } + } +} + private struct FeatureMessageAttachmentsView: View { let attachments: [FeatureMessageAttachment] @State private var previewedAttachment: FeatureMessageAttachment? diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index f34e528286f6..cb8655630a86 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -3,8 +3,249 @@ import Testing import UIKit @testable import T3Code -@Suite("Transcript viewport anchoring") +@Suite("Transcript viewport and timestamp gestures") struct TranscriptViewportGeometryTests { + @Test + func timestampRevealMovesTheVisibleTranscriptAsOneSheetAndResetsOnRelease() { + var reveal = TranscriptTimestampRevealModel() + + reveal.begin(anchorYsByMessageID: [:]) + #expect(!reveal.isActive) + reveal.update(translationX: -32) + #expect(reveal == TranscriptTimestampRevealModel()) + + reveal.begin( + anchorYsByMessageID: [ + "assistant-1": 140, + "assistant-2": 72, + "user-1": 44, + ] + ) + reveal.update(translationX: -32) + #expect(reveal.isActive) + #expect(reveal.width == 32) + #expect(reveal.anchorY(for: "assistant-1") == 140) + #expect(reveal.anchorY(for: "assistant-2") == 72) + #expect(reveal.anchorY(for: "user-1") == 44) + #expect(reveal.anchorY(for: "offscreen-assistant") == nil) + + reveal.refresh( + anchorYsByMessageID: [ + "assistant-2": 80, + "newly-completed-assistant": 12, + ] + ) + #expect(reveal.width == 32) + #expect(reveal.anchorY(for: "assistant-1") == nil) + #expect(reveal.anchorY(for: "assistant-2") == 80) + #expect(reveal.anchorY(for: "newly-completed-assistant") == 12) + + reveal.refresh(anchorYsByMessageID: [:]) + #expect(reveal.isActive) + #expect(reveal.anchorY(for: "assistant-2") == 80) + #expect(reveal.hasAnchor { $0 == "assistant-2" }) + #expect(!reveal.hasAnchor { $0 == "removed-message" }) + + reveal.finish() + #expect(reveal == TranscriptTimestampRevealModel()) + reveal.refresh(anchorYsByMessageID: ["late-assistant": 24]) + #expect(reveal == TranscriptTimestampRevealModel()) + } + + @Test + func timestampAnchorClampsInsideItsMessageRow() { + #expect( + TranscriptTimestampRevealGeometry.anchorCenterY( + requestedY: 1_240, + rowHeight: 4_000 + ) == 1_240 + ) + #expect( + TranscriptTimestampRevealGeometry.anchorCenterY( + requestedY: -20, + rowHeight: 4_000 + ) == TranscriptTimestampRevealGeometry.minimumAnchorInset + ) + #expect( + TranscriptTimestampRevealGeometry.anchorCenterY( + requestedY: 4_020, + rowHeight: 4_000 + ) == 4_000 - TranscriptTimestampRevealGeometry.minimumAnchorInset + ) + #expect( + TranscriptTimestampRevealGeometry.anchorCenterY( + requestedY: 12, + rowHeight: 24 + ) == 12 + ) + #expect( + TranscriptTimestampRevealGeometry.resolvedAnchorCenterY( + requestedY: 5, + rowHeight: 200 + ) == 5 + ) + #expect( + TranscriptTimestampRevealGeometry.resolvedAnchorCenterY( + requestedY: 887, + rowHeight: 905 + ) == 887 + ) + #expect( + TranscriptTimestampRevealGeometry.resolvedAnchorCenterY( + requestedY: nil, + rowHeight: 200 + ) == 100 + ) + } + + @Test + func timestampAnchorsEveryVisibleRowInsideItsVisibleViewportSlice() { + let viewport = CGRect(x: 0, y: 1_000, width: 440, height: 700) + + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 900, width: 404, height: 400), + viewportBounds: viewport + ) == 250 + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 1_340, width: 404, height: 100), + viewportBounds: viewport + ) == 50 + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 800, width: 404, height: 1_100), + viewportBounds: viewport + ) == 550 + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 1_690, width: 404, height: 200), + viewportBounds: viewport + ) == 5 + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 1_700, width: 404, height: 100), + viewportBounds: viewport + ) == nil + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 1_800, width: 404, height: 100), + viewportBounds: viewport + ) == nil + ) + #expect( + TranscriptTimestampRevealGeometry.visibleAnchorCenterY( + rowFrame: CGRect(x: 18, y: 800, width: 404, height: 100), + viewportBounds: viewport + ) == nil + ) + } + + @Test + func timestampRevealClampsAndClaimsOnlyDeliberateLeftwardHorizontalPans() { + #expect(TranscriptTimestampRevealGeometry.width(translationX: 24) == 0) + #expect( + TranscriptTimestampRevealGeometry.width(translationX: -200) + == TranscriptTimestampRevealGeometry.maximumWidth + ) + #expect( + TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -24, velocityY: 4) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -4, velocityY: 24) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 24, velocityY: 4) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -10, velocityY: 9) + ) + } + + @Test + func timestampEligibilityRequiresCompletedUserOrAssistantWithRealDate() { + let timestamp = Date(timeIntervalSince1970: 1_780_000_000) + + #expect( + FeatureMessageTimestampMetadata.isEligible( + role: .user, + state: .complete, + createdAt: timestamp + ) + ) + #expect( + FeatureMessageTimestampMetadata.isEligible( + role: .assistant, + state: .complete, + createdAt: timestamp + ) + ) + for state in [FeatureMessageState.queued, .streaming, .failed] { + #expect( + !FeatureMessageTimestampMetadata.isEligible( + role: .assistant, + state: state, + createdAt: timestamp + ) + ) + } + for role in [FeatureMessageRole.tool, .system] { + #expect( + !FeatureMessageTimestampMetadata.isEligible( + role: role, + state: .complete, + createdAt: timestamp + ) + ) + } + #expect( + !FeatureMessageTimestampMetadata.isEligible( + role: .user, + state: .complete, + createdAt: .distantPast + ) + ) + } + + @Test + func timestampAccessibilityLabelsOnlyEligibleConversationRoles() { + #expect(FeatureMessageTimestampMetadata.accessibilityLabel(for: .user) == "Sent") + #expect(FeatureMessageTimestampMetadata.accessibilityLabel(for: .assistant) == "Received") + #expect(FeatureMessageTimestampMetadata.accessibilityLabel(for: .tool) == nil) + #expect(FeatureMessageTimestampMetadata.accessibilityLabel(for: .system) == nil) + } + + @Test + @MainActor + func selectableMarkdownDoesNotStealTimestampSwipeButHorizontalContentDoes() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let textView = UITextView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + textView.contentSize = CGSize(width: 480, height: 120) + host.addSubview(textView) + + #expect( + !TranscriptTimestampGestureOwnership.descendantOwnsHorizontalInteraction( + from: textView, + host: host + ) + ) + + let codeScroller = UIScrollView(frame: CGRect(x: 0, y: 120, width: 120, height: 120)) + codeScroller.contentSize = CGSize(width: 480, height: 120) + host.addSubview(codeScroller) + #expect( + TranscriptTimestampGestureOwnership.descendantOwnsHorizontalInteraction( + from: codeScroller, + host: host + ) + ) + } + @Test func firstLoadedTranscriptAnchorsToLatestMessage() { let empty = TranscriptViewportGeometry( @@ -99,6 +340,22 @@ struct TranscriptViewportGeometryTests { isInteracting: true ) == nil ) + #expect( + TranscriptViewportGeometry.shouldMaintainBottomAnchor( + isInitialLoad: false, + wasNearBottom: false, + isTimestampRevealActive: true, + wasMaintainingBottomAnchor: true + ) + ) + #expect( + !TranscriptViewportGeometry.shouldMaintainBottomAnchor( + isInitialLoad: false, + wasNearBottom: false, + isTimestampRevealActive: false, + wasMaintainingBottomAnchor: true + ) + ) } @Test From 6d96bd988a1274555fd8c6b150f5307a9fb9d40a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 21:37:22 +1000 Subject: [PATCH 2/4] fix(swift-ios): keep message identity and anchors through timestamp reveal Two review findings on the swipe-to-reveal timestamps feature: Eligibility (streaming -> complete) decided a structural branch around the message view, so completing a stream rebuilt the row and dropped MarkdownMessageView's @State, breaking its streaming -> complete promote path. The message view now sits at one fixed structural position and only the timestamp overlay is conditional; the accessibility modifier branches on role, which never changes, and announces an empty value while a message is still streaming. Dismissing the reveal zeroed the anchors immediately, so timestamps faded out while drifting to their row midpoints and the transcript re-anchored mid-dismissal. beginDismissal() now only zeroes the width; the anchors survive the slide-back and are cleared by the release animation's completion, guarded by a generation counter so a stale completion cannot settle a newer gesture. The collection view mirrors timestampReveal.isActive on every update so resets that bypass the completion cannot leave the bottom-anchor suppression stuck on. Co-Authored-By: Claude --- .../Features/Chat/ThreadDetailView.swift | 95 +++++++-- .../TranscriptViewportGeometryTests.swift | 188 ++++++++++++++++++ 2 files changed, 261 insertions(+), 22 deletions(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 0b82b12f31ba..6a8df7c766d9 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1217,10 +1217,12 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { case .changed: timestampReveal.update(translationX: gesture.translation(in: gesture.view).x) case .ended, .cancelled, .failed: - timestampReveal.finish(reduceMotion: UIAccessibility.isReduceMotionEnabled) visibleTimestampAnchorYs = nil - (collectionView as? BottomAnchoredTranscriptCollectionView)? - .isTimestampRevealActive = false + timestampReveal.release(reduceMotion: UIAccessibility.isReduceMotionEnabled) { + [weak self] in + (self?.collectionView as? BottomAnchoredTranscriptCollectionView)? + .isTimestampRevealActive = false + } default: break } @@ -1368,6 +1370,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { timestampReveal.resetIfNoEligibleMessagesRemain(in: messagesByID) if let anchoredCollectionView = collectionView as? BottomAnchoredTranscriptCollectionView { + // A reset above can settle the reveal without the dismissal + // animation's completion, so mirror the model here rather than + // waiting for it. + anchoredCollectionView.isTimestampRevealActive = timestampReveal.isActive anchoredCollectionView.maintainsBottomAnchor = TranscriptViewportGeometry.shouldMaintainBottomAnchor( isInitialLoad: isInitialLoad, @@ -1787,6 +1793,13 @@ struct TranscriptTimestampRevealModel: Equatable { self.anchorYsByMessageID = anchorYsByMessageID } + /// Starts the slide back to rest while a reveal is still active, so every + /// timestamp holds its anchor while it fades out instead of drifting to + /// its row midpoint. `finish()` drops the anchors once the animation lands. + mutating func beginDismissal() { + width = 0 + } + mutating func finish() { width = 0 anchorYsByMessageID = [:] @@ -1826,11 +1839,20 @@ enum FeatureMessageTimestampMetadata { case .tool, .system: nil } } + + /// Empty while the message is still streaming or has no known send time, so + /// VoiceOver has nothing to announce for it. + static func accessibilityValue(for message: FeatureMessage) -> String { + guard isEligible(message) else { return "" } + return message.createdAt.formatted(date: .omitted, time: .shortened) + } } @MainActor -private final class FeatureTimestampRevealState: ObservableObject { +final class FeatureTimestampRevealState: ObservableObject { @Published private var model = TranscriptTimestampRevealModel() + // A release still animating must not settle the anchors of a newer gesture. + private var releaseGeneration = 0 var width: CGFloat { model.width @@ -1845,6 +1867,7 @@ private final class FeatureTimestampRevealState: ObservableObject { } func begin(anchorYsByMessageID: [String: CGFloat]) { + releaseGeneration += 1 updateWithoutAnimation { $0.begin(anchorYsByMessageID: anchorYsByMessageID) } } @@ -1856,11 +1879,27 @@ private final class FeatureTimestampRevealState: ObservableObject { updateWithoutAnimation { $0.refresh(anchorYsByMessageID: anchorYsByMessageID) } } - func finish(reduceMotion: Bool) { - var finished = model - finished.finish() - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { - model = finished + /// Animates the sheet back to rest. The anchors outlive the animation so + /// timestamps fade out in place, and the reveal stays active for layout, + /// until `onSettled` reports the slide-back has landed. + func release(reduceMotion: Bool, onSettled: @escaping () -> Void) { + guard model.isActive else { + onSettled() + return + } + var dismissing = model + dismissing.beginDismissal() + guard let animation: Animation = reduceMotion ? nil : .easeOut(duration: 0.2) else { + updateWithoutAnimation { $0.finish() } + onSettled() + return + } + releaseGeneration += 1 + let generation = releaseGeneration + withAnimation(animation) { + model = dismissing + } completion: { + self.settle(generation: generation, onSettled: onSettled) } } @@ -1876,6 +1915,12 @@ private final class FeatureTimestampRevealState: ObservableObject { if !hasEligibleMessage { reset() } } + private func settle(generation: Int, onSettled: () -> Void) { + guard generation == releaseGeneration else { return } + updateWithoutAnimation { $0.finish() } + onSettled() + } + private func updateWithoutAnimation( _ update: (inout TranscriptTimestampRevealModel) -> Void ) { @@ -1890,17 +1935,21 @@ private final class FeatureTimestampRevealState: ObservableObject { } } -private struct FeatureTimestampRevealMessageView: View { +struct FeatureTimestampRevealMessageView: View { let message: FeatureMessage let imageContext: MarkdownImageContext? @ObservedObject var reveal: FeatureTimestampRevealState var body: some View { let revealWidth = reveal.width - if FeatureMessageTimestampMetadata.isEligible(message) { - let requestedAnchorY = reveal.anchorY(for: message.id) - FeatureMessageView(message: message, imageContext: imageContext) - .overlay { + // The message view stays at one fixed structural position. Branching it + // on eligibility would recreate it when a streamed message completes, + // dropping MarkdownMessageView's state and its streaming -> complete + // promote path, so only the timestamp overlay is conditional. + FeatureMessageView(message: message, imageContext: imageContext) + .overlay { + if FeatureMessageTimestampMetadata.isEligible(message) { + let requestedAnchorY = reveal.anchorY(for: message.id) GeometryReader { geometry in Text(message.createdAt, format: .dateTime.hour().minute()) .font(T3Typography.supporting.monospacedDigit()) @@ -1925,11 +1974,8 @@ private struct FeatureTimestampRevealMessageView: View { } .allowsHitTesting(false) } - .offset(x: -revealWidth) - } else { - FeatureMessageView(message: message, imageContext: imageContext) - .offset(x: -revealWidth) - } + } + .offset(x: -revealWidth) } } @@ -2641,13 +2687,18 @@ private struct FeatureWorkLogView: View { private struct FeatureMessageTimestampAccessibilityModifier: ViewModifier { let message: FeatureMessage + // The branch depends only on the role, which never changes for a message. + // Branching on eligibility instead would move the content between the two + // conditional branches when a stream completes, rebuilding the message + // subtree and discarding the markdown state its streaming -> complete + // promote path relies on. Eligibility only decides whether there is a + // value worth announcing. @ViewBuilder func body(content: Content) -> some View { - if FeatureMessageTimestampMetadata.isEligible(message), - let label = FeatureMessageTimestampMetadata.accessibilityLabel(for: message.role) { + if let label = FeatureMessageTimestampMetadata.accessibilityLabel(for: message.role) { content.accessibilityCustomContent( Text(label), - Text(message.createdAt.formatted(date: .omitted, time: .shortened)), + Text(FeatureMessageTimestampMetadata.accessibilityValue(for: message)), importance: .high ) } else { diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index cb8655630a86..ae05f6a02b1c 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -1,4 +1,5 @@ import CoreGraphics +import SwiftUI import Testing import UIKit @testable import T3Code @@ -52,6 +53,136 @@ struct TranscriptViewportGeometryTests { #expect(reveal == TranscriptTimestampRevealModel()) } + @Test + func timestampDismissalHoldsAnchorsUntilItSettles() { + var reveal = TranscriptTimestampRevealModel() + + reveal.begin(anchorYsByMessageID: ["assistant-1": 140, "user-1": 44]) + reveal.update(translationX: -60) + #expect(reveal.width == 60) + + // Releasing zeroes the width inside the same animation that slides the + // sheet back, but the anchors must survive it: they are what keeps each + // timestamp vertically in place, and they also keep the reveal active so + // the transcript does not re-anchor its scroll position mid-dismissal. + reveal.beginDismissal() + #expect(reveal.width == 0) + #expect(reveal.isActive) + #expect(reveal.anchorY(for: "assistant-1") == 140) + #expect(reveal.anchorY(for: "user-1") == 44) + + reveal.finish() + #expect(reveal == TranscriptTimestampRevealModel()) + } + + @Test + @MainActor + func completingAStreamedMessageKeepsItsRenderedMarkdownView() async throws { + let source = "Distinctive streamed paragraph that completes mid-reveal." + // Seed the cache so the streaming row already renders blocks instead of + // falling back to plain text, giving the hosted text view an identity to + // compare across the streaming -> complete transition. + _ = MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision(source) + ) + let sentAt = Date(timeIntervalSince1970: 1_780_000_000) + let message = MessageTransitionHarness.MessageBox( + FeatureMessage( + id: "assistant-1", + role: .assistant, + text: source, + createdAt: sentAt, + state: .streaming + ) + ) + let reveal = FeatureTimestampRevealState() + let controller = UIHostingController( + rootView: MessageTransitionHarness( + message: message, + reveal: reveal + ) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 300)) + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + + let streamingTextView = try #require( + await awaitedTextView(in: controller.view, containing: "Distinctive streamed") + ) + + // Control: republishing the same streamed state must be a no-op for + // identity, otherwise the harness itself is rebuilding the tree. + message.send() + let restreamedTextView = try #require( + await awaitedTextView(in: controller.view, containing: "Distinctive streamed") + ) + #expect( + ObjectIdentifier(streamingTextView) == ObjectIdentifier(restreamedTextView), + "Republishing the same streamed state rebuilt the markdown view" + ) + + message.value.state = .complete + #expect(FeatureMessageTimestampMetadata.isEligible(message.value)) + message.send() + + // Completing the stream flips eligibility, which is what decides whether + // the reveal overlay is attached. The message view underneath must keep + // its identity so the streamed render and any active selection survive. + let completedTextView = try #require( + await awaitedTextView(in: controller.view, containing: "Distinctive streamed") + ) + #expect( + ObjectIdentifier(streamingTextView) == ObjectIdentifier(completedTextView), + "Completing a streamed message rebuilt its markdown view instead of reusing it" + ) + } + + @Test + @MainActor + func revealReleaseSettlesAndReportsBackToTheTranscript() async throws { + let reveal = FeatureTimestampRevealState() + reveal.begin(anchorYsByMessageID: ["assistant-1": 140]) + reveal.update(translationX: -60) + #expect(reveal.isActive) + + var settledReduceMotion = 0 + reveal.release(reduceMotion: true) { settledReduceMotion += 1 } + #expect(settledReduceMotion == 1) + #expect(!reveal.isActive) + #expect(reveal.anchorY(for: "assistant-1") == nil) + + // The animated slide-back must also land: a reveal that never settles + // would keep the transcript pinned to the reveal layout forever. + reveal.begin(anchorYsByMessageID: ["assistant-2": 72]) + reveal.update(translationX: -40) + var settledAnimated = 0 + reveal.release(reduceMotion: false) { settledAnimated += 1 } + for _ in 0..<200 where settledAnimated == 0 { + await Task.yield() + try await Task.sleep(for: .milliseconds(10)) + } + #expect(settledAnimated == 1) + #expect(!reveal.isActive) + + // A fresh gesture right after a release must find a clean, working reveal. + reveal.begin(anchorYsByMessageID: ["user-1": 30]) + reveal.update(translationX: -40) + var settledAgain = 0 + reveal.release(reduceMotion: false) { settledAgain += 1 } + for _ in 0..<200 where settledAgain == 0 { + await Task.yield() + try await Task.sleep(for: .milliseconds(10)) + } + #expect(settledAgain == 1) + + reveal.begin(anchorYsByMessageID: ["user-2": 60]) + reveal.update(translationX: -50) + #expect(reveal.isActive) + #expect(reveal.width == 50) + #expect(reveal.anchorY(for: "user-2") == 60) + } + @Test func timestampAnchorClampsInsideItsMessageRow() { #expect( @@ -482,3 +613,60 @@ struct TranscriptViewportGeometryTests { #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) } } + +@MainActor +private func awaitedTextView( + in host: UIView, + containing fragment: String +) async -> UITextView? { + for _ in 0..<30 { + host.setNeedsLayout() + host.layoutIfNeeded() + if let match = textViews(in: host).first(where: { $0.text.contains(fragment) }) { + return match + } + try? await Task.sleep(for: .milliseconds(10)) + } + return nil +} + +@MainActor +private func textViews(in view: UIView) -> [UITextView] { + view.subviews.reduce(into: []) { found, subview in + if let textView = subview as? UITextView { + found.append(textView) + } + found.append(contentsOf: textViews(in: subview)) + } +} + +/// Publishes the message the way the transcript does: the same hosted view +/// position receives a new message value, so identity survives or breaks on the +/// strength of the view structure alone. +@MainActor +private struct MessageTransitionHarness: View { + @MainActor + final class MessageBox: ObservableObject { + @Published var value: FeatureMessage + + init(_ value: FeatureMessage) { + self.value = value + } + + func send() { + let current = value + value = current + } + } + + @ObservedObject var message: MessageBox + @ObservedObject var reveal: FeatureTimestampRevealState + + var body: some View { + FeatureTimestampRevealMessageView( + message: message.value, + imageContext: nil, + reveal: reveal + ) + } +} From e87bce8fc0492e422cdba4f769b13570ae90dc68 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:29:33 +1000 Subject: [PATCH 3/4] fix(swift-ios): restore bottom anchor during timestamp reveal --- apps/swift-ios/Features/Chat/ThreadDetailView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 6a8df7c766d9..ce6a87716033 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1454,6 +1454,12 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { in: collectionView ) ) + if shouldFollowBottom { + self.scrollToBottom( + collectionView, + animated: !isInitialLoad && lastIDChanged + ) + } return } if shouldFollowBottom { From bfb058405a28d78e87eec3923475720180744507 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:51:46 +1000 Subject: [PATCH 4/4] fix(swift-ios): refresh timestamp anchors after scrolling --- apps/swift-ios/Features/Chat/ThreadDetailView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index ce6a87716033..f4b23cfb6ff2 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1449,17 +1449,18 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { ) } collectionView.layoutIfNeeded() - self.timestampReveal.refresh( - anchorYsByMessageID: self.visibleTimestampAnchors( - in: collectionView - ) - ) if shouldFollowBottom { self.scrollToBottom( collectionView, - animated: !isInitialLoad && lastIDChanged + animated: false ) } + collectionView.layoutIfNeeded() + self.timestampReveal.refresh( + anchorYsByMessageID: self.visibleTimestampAnchors( + in: collectionView + ) + ) return } if shouldFollowBottom {