diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 44647e039629..53e19023ccee 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -657,7 +657,6 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { ) collectionView.backgroundColor = T3Colors.uiBackground collectionView.alwaysBounceVertical = true - collectionView.keyboardDismissMode = .onDrag collectionView.delaysContentTouches = false collectionView.contentInsetAdjustmentBehavior = .never collectionView.isPrefetchingEnabled = true @@ -710,7 +709,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 @@ -731,12 +732,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var markdownPrefetches: [String: MarkdownPrefetch] = [:] private var onLoadEarlier: (() -> Void)? private var onDismissKeyboard: (() -> Void)? + private let timestampReveal = FeatureTimestampRevealState() + private var verticalDragStartOffset: CGFloat? + private lazy var timestampPanGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleTimestampPan(_:)) + ) deinit { markdownPrefetches.values.forEach { $0.task.cancel() } } func connect(to collectionView: UICollectionView) { + timestampPanGesture.cancelsTouchesInView = false + timestampPanGesture.delegate = self + collectionView.addGestureRecognizer(timestampPanGesture) + let registration = UICollectionView.CellRegistration { [weak self] cell, _, messageID in if messageID == FeatureTranscriptCollectionView.loadEarlierID { @@ -770,7 +781,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } cell.contentConfiguration = UIHostingConfiguration { - FeatureMessageView(message: message) + FeatureTimestampRevealMessageView( + message: message, + reveal: self?.timestampReveal ?? FeatureTimestampRevealState() + ) .frame(maxWidth: .infinity, alignment: .leading) } .margins(.all, 0) @@ -791,6 +805,71 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { collectionView.delegate = self } + @objc private func handleTimestampPan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .changed: + timestampReveal.width = TranscriptTimestampRevealGeometry.width( + translationX: gesture.translation(in: gesture.view).x + ) + case .ended, .cancelled, .failed: + guard timestampReveal.width > 0 else { return } + let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.22 + withAnimation(.easeOut(duration: duration)) { + timestampReveal.width = 0 + } + default: + break + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === timestampPanGesture, + let collectionView = gestureRecognizer.view as? UICollectionView, + let pan = gestureRecognizer as? UIPanGestureRecognizer else { + return true + } + let velocity = pan.velocity(in: collectionView) + guard collectionView.effectiveUserInterfaceLayoutDirection == .leftToRight else { + return false + } + return TranscriptTimestampRevealGeometry.shouldBegin( + velocityX: velocity.x, + velocityY: velocity.y + ) && !isNestedHorizontalScroller( + at: pan.location(in: collectionView), + in: collectionView + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + guard let collectionView = timestampPanGesture.view as? UICollectionView else { + return false + } + return (gestureRecognizer === timestampPanGesture + && otherGestureRecognizer === collectionView.panGestureRecognizer) + || (otherGestureRecognizer === timestampPanGesture + && gestureRecognizer === collectionView.panGestureRecognizer) + } + + private func isNestedHorizontalScroller( + at point: CGPoint, + in collectionView: UICollectionView + ) -> Bool { + var candidate = collectionView.hitTest(point, with: nil) + while let view = candidate, view !== collectionView { + if let scrollView = view as? UIScrollView, + scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width > scrollView.bounds.width + 1 { + return true + } + candidate = view.superview + } + return false + } + func update( threadID: String, messages: [FeatureMessage], @@ -1154,12 +1233,26 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView) + verticalDragStartOffset = TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: velocity.x, + velocityY: velocity.y + ) ? scrollView.contentOffset.y : nil + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let startOffset = verticalDragStartOffset, + abs(scrollView.contentOffset.y - startOffset) > 0.5 else { + return + } + verticalDragStartOffset = nil (scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false scrollView.window?.endEditing(false) onDismissKeyboard?() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + verticalDragStartOffset = nil guard !decelerate else { return } updateBottomAnchor(for: scrollView) } @@ -1177,6 +1270,61 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } } +enum TranscriptTimestampRevealGeometry { + static let maximumWidth: CGFloat = 76 + static let minimumHorizontalVelocity: CGFloat = 120 + static let horizontalIntentRatio: CGFloat = 1.35 + + static func shouldBegin(velocityX: CGFloat, velocityY: CGFloat) -> Bool { + let horizontalSpeed = abs(velocityX) + return velocityX < 0 + && horizontalSpeed >= minimumHorizontalVelocity + && horizontalSpeed >= abs(velocityY) * horizontalIntentRatio + } + + static func width(translationX: CGFloat) -> CGFloat { + min(maximumWidth, max(0, -translationX)) + } + + static func hasVerticalIntent(velocityX: CGFloat, velocityY: CGFloat) -> Bool { + abs(velocityY) > abs(velocityX) + } +} + +@MainActor +private final class FeatureTimestampRevealState: ObservableObject { + @Published var width: CGFloat = 0 +} + +private struct FeatureTimestampRevealMessageView: View { + let message: FeatureMessage + @ObservedObject var reveal: FeatureTimestampRevealState + + var body: some View { + FeatureMessageView(message: message) + .overlay(alignment: .topTrailing) { + if message.createdAt != .distantPast { + 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 + ) + .padding(.vertical, 5) + .background(T3Colors.surface, in: Capsule()) + .frame(width: reveal.width, alignment: .trailing) + .clipped() + .opacity(reveal.width > 0 ? 1 : 0) + .accessibilityHidden(true) + } + } + } +} + private struct FeatureLoadEarlierTurnsButton: View { let isLoading: Bool let onLoad: () -> Void @@ -1532,6 +1680,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 { @@ -1553,6 +1702,7 @@ struct FeatureMessageView: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .tool: DisclosureGroup { Text(message.text) @@ -1569,12 +1719,14 @@ struct FeatureMessageView: View { .padding(.vertical, 6) .frame(minHeight: T3Metrics.minimumTapTarget) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .system: Text(message.text) .font(T3Typography.supporting) .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, alignment: .center) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) } } @@ -1589,6 +1741,31 @@ struct FeatureMessageView: View { } } +private struct FeatureMessageTimestampAccessibilityModifier: ViewModifier { + let message: FeatureMessage + + @ViewBuilder + func body(content: Content) -> some View { + if message.createdAt == .distantPast { + content + } else { + content.accessibilityCustomContent( + Text(accessibilityLabel), + Text(message.createdAt.formatted(date: .omitted, time: .shortened)), + importance: .default + ) + } + } + + private var accessibilityLabel: String { + switch message.role { + case .user: "Sent" + case .assistant: "Received" + case .tool, .system: "Timestamp" + } + } +} + private struct FeatureMessageAttachmentsView: View { let attachments: [FeatureMessageAttachment] @State private var previewedAttachment: FeatureMessageAttachment? diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index c63745ee727a..4beb5832d288 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -130,7 +130,9 @@ struct HomeThreadMetadataTests { path: "/work/t3code" ), ], - providers: [FeatureProvider(id: "claude", name: "Claude")] + providersByEnvironment: [ + "device": [FeatureProvider(id: "claude", name: "Claude")], + ] ) #expect(thread.homeEnvironmentLabel(in: snapshot) == "steambox") @@ -162,9 +164,11 @@ struct HomeThreadMetadataTests { ), ], threads: [knownThread, customThread], - providers: [ - FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), - FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + providersByEnvironment: [ + "device": [ + FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), + FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + ], ] ) diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index 09ab6120a927..a0c8a3742978 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -1,8 +1,57 @@ import Testing @testable import T3Code -@Suite("Transcript viewport anchoring") +@Suite("Transcript viewport and timestamp gestures") struct TranscriptViewportGeometryTests { + @Test + func timestampRevealTracksLeftwardDragWithinBounds() { + #expect(TranscriptTimestampRevealGeometry.width(translationX: -32) == 32) + } + + @Test + func timestampRevealClampsAtRestAndMaximumWidth() { + #expect(TranscriptTimestampRevealGeometry.width(translationX: 24) == 0) + #expect( + TranscriptTimestampRevealGeometry.width(translationX: -200) + == TranscriptTimestampRevealGeometry.maximumWidth + ) + } + + @Test + func timestampRevealClaimsOnlyDeliberateLeftwardHorizontalPans() { + #expect( + TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -240, velocityY: 40) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 240) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 240, velocityY: 40) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 2) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -120, velocityY: 100) + ) + } + + @Test + func keyboardDismissalTracksOnlyVerticalTranscriptPans() { + #expect( + TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: -40, + velocityY: 240 + ) + ) + #expect( + !TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: -240, + velocityY: 40 + ) + ) + } + @Test func firstLoadedTranscriptAnchorsToLatestMessage() { let empty = TranscriptViewportGeometry( diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..93c673cd284c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Native iPhone thread transcript](./user/swiftui-thread-transcript.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/swiftui-thread-transcript.md b/docs/user/swiftui-thread-transcript.md new file mode 100644 index 000000000000..90ac0bf80783 --- /dev/null +++ b/docs/user/swiftui-thread-transcript.md @@ -0,0 +1,6 @@ +# Native iPhone thread transcript + +In the native iPhone app, swipe left across the transcript to reveal the time on each message. +The gesture requires a deliberate horizontal swipe so ordinary vertical scrolling and text +selection keep their normal behavior. VoiceOver exposes the same time as custom content on each +message without requiring the gesture.