diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a52d7aa81379..87a3a8c170a6 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -32,7 +32,7 @@ 3669 cmuxTests/CLIGenericHookPersistenceTests.swift 3397 Sources/CmuxConfig.swift 3364 cmuxTests/TabManagerSessionSnapshotTests.swift -3053 Sources/Update/UpdateTitlebarAccessory.swift +3058 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift 2606 Sources/KeyboardShortcutSettings.swift @@ -226,6 +226,7 @@ 519 Sources/CmuxConfigExecutor.swift 518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift 518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift +516 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 516 Sources/TerminalImageTransfer.swift 514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift 514 cmuxUITests/UpdatePillUITests.swift diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 2a48045e5844..cff6849307b5 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -84,6 +84,8 @@ struct ChatTranscriptTableView: UIViewRepresentable { private var topRequestKey: String? private var lastScrollToBottomRequest = 0 private var isHandlingLayout = false + private var isApplyingDataUpdate = false + private var pendingContentUpdateAnchor: ChatTranscriptTableAnchor? private weak var tableView: ChatTranscriptUITableView? private var isAtBottom: Binding #if DEBUG @@ -97,13 +99,18 @@ struct ChatTranscriptTableView: UIViewRepresentable { func attach(_ tableView: ChatTranscriptUITableView) { self.tableView = tableView - tableView.afterLayout = { [weak self, weak tableView] oldBoundsSize, oldContentSize, oldViewport in + tableView.anchorBeforeLayout = { [weak self, weak tableView] in + guard let self, let tableView else { return nil } + return self.firstVisibleAnchor(in: tableView) + } + tableView.afterLayout = { [weak self, weak tableView] oldBoundsSize, oldContentSize, oldViewport, oldAnchor in guard let self, let tableView else { return } self.handleLayoutChange( in: tableView, oldBoundsSize: oldBoundsSize, oldContentSize: oldContentSize, - oldViewport: oldViewport + oldViewport: oldViewport, + oldAnchor: oldAnchor ) } } @@ -120,29 +127,34 @@ struct ChatTranscriptTableView: UIViewRepresentable { || configuration.agentState != agentState let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest lastScrollToBottomRequest = scrollToBottomRequest - let wasAtBottom = isAtBottom.wrappedValue - || distanceFromBottom(in: tableView) <= chatTranscriptAtBottomThreshold + let wasAtBottom = distanceFromBottom(in: tableView) <= chatTranscriptAtBottomThreshold let anchor = firstVisibleAnchor(in: tableView) guard shouldReload else { if shouldScrollToBottom { + pendingContentUpdateAnchor = nil scrollToBottom(in: tableView, animated: true) } updateBottomState(from: tableView) return } + pendingContentUpdateAnchor = nil items = nextItems expandedIDs = configuration.expandedIDs agentState = configuration.agentState + isApplyingDataUpdate = true + defer { isApplyingDataUpdate = false } tableView.reloadData() tableView.layoutIfNeeded() if shouldScrollToBottom || wasAtBottom { + pendingContentUpdateAnchor = nil scrollToBottom(in: tableView, animated: false) } else if let anchor { restore(anchor, in: tableView) + pendingContentUpdateAnchor = anchor } #if DEBUG applyDebugInitialScrollIfNeeded(in: tableView) @@ -179,17 +191,25 @@ struct ChatTranscriptTableView: UIViewRepresentable { requestOlderHistoryIfNeeded(in: tableView) } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + pendingContentUpdateAnchor = nil + } + private func handleLayoutChange( in tableView: ChatTranscriptUITableView, oldBoundsSize: CGSize, oldContentSize: CGSize, - oldViewport: MobileScrollViewportSnapshot? + oldViewport: MobileScrollViewportSnapshot?, + oldAnchor: ChatTranscriptTableAnchor? ) { guard !isHandlingLayout else { return } let boundsChanged = abs(oldBoundsSize.height - tableView.bounds.height) > 0.5 || abs(oldBoundsSize.width - tableView.bounds.width) > 0.5 let contentChanged = abs(oldContentSize.height - tableView.contentSize.height) > 0.5 guard boundsChanged || contentChanged else { + if !isApplyingDataUpdate { + pendingContentUpdateAnchor = nil + } updateBottomState(from: tableView) return } @@ -201,11 +221,20 @@ struct ChatTranscriptTableView: UIViewRepresentable { updateBottomState(from: tableView) return } + if isApplyingDataUpdate { + updateBottomState(from: tableView) + return + } if boundsChanged, let oldViewport { restoreKeyboardViewport(snapshot: oldViewport, in: tableView) - } else if isAtBottom.wrappedValue { + } else if contentChanged, let pendingContentUpdateAnchor { + restore(pendingContentUpdateAnchor, in: tableView) + self.pendingContentUpdateAnchor = nil + } else if oldViewport?.wasAtBottom == true { scrollToBottom(in: tableView, animated: false) + } else if contentChanged, let oldAnchor { + restore(oldAnchor, in: tableView) } updateBottomState(from: tableView) } @@ -231,12 +260,14 @@ struct ChatTranscriptTableView: UIViewRepresentable { y: clampedOffsetY(rect.minY + anchor.offsetFromRowTop, in: tableView) ) tableView.setContentOffset(offset, animated: false) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() } private func scrollToBottom(in tableView: UITableView, animated: Bool) { tableView.layoutIfNeeded() let targetY = maxOffsetY(in: tableView) tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: animated) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() setAtBottom(true) } @@ -302,6 +333,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { let maxY = maxOffsetY(in: tableView) let targetY = clampedOffsetY(minY + ((maxY - minY) * 0.5), in: tableView) tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: false) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() setAtBottom(false) } #endif @@ -320,6 +352,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { CGPoint(x: tableView.contentOffset.x, y: offsetY), animated: false ) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() setAtBottom(snapshot.wasAtBottom) } } @@ -480,9 +513,4 @@ private enum ChatTranscriptTableItem: Equatable { } } -private struct ChatTranscriptTableAnchor { - let id: String - let offsetFromRowTop: CGFloat -} - #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift index 7f900e21f119..0af9e6d429f1 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift @@ -7,8 +7,10 @@ final class ChatTranscriptUITableView: UITableView { var afterLayout: (( _ oldBoundsSize: CGSize, _ oldContentSize: CGSize, - _ oldViewport: MobileScrollViewportSnapshot? + _ oldViewport: MobileScrollViewportSnapshot?, + _ oldAnchor: ChatTranscriptTableAnchor? ) -> Void)? + var anchorBeforeLayout: (() -> ChatTranscriptTableAnchor?)? #if DEBUG var keyboardDebugEventCount = 0 var keyboardDebugOverlap: CGFloat = 0 @@ -44,10 +46,15 @@ final class ChatTranscriptUITableView: UITableView { } #endif + override var contentOffset: CGPoint { + didSet { recordViewport() } + } + override func layoutSubviews() { let oldBoundsSize = lastBoundsSize let oldContentSize = lastContentSize let oldViewport = lastViewport + let oldAnchor = anchorBeforeLayout?() super.layoutSubviews() lastBoundsSize = bounds.size lastContentSize = contentSize @@ -55,7 +62,7 @@ final class ChatTranscriptUITableView: UITableView { #if DEBUG updateDebugAccessibilityValue() #endif - afterLayout?(oldBoundsSize, oldContentSize, oldViewport) + afterLayout?(oldBoundsSize, oldContentSize, oldViewport, oldAnchor) } func keyboardViewportSnapshot() -> MobileScrollViewportSnapshot { @@ -72,6 +79,13 @@ final class ChatTranscriptUITableView: UITableView { restoreKeyboardViewport(snapshot, boundsHeight: bounds.height) } + func recordCurrentViewport() { + recordViewport() + #if DEBUG + updateDebugAccessibilityValue() + #endif + } + func restoreKeyboardViewport( _ snapshot: MobileScrollViewportSnapshot, boundsHeight: CGFloat @@ -83,10 +97,7 @@ final class ChatTranscriptUITableView: UITableView { adjustedBottomInset: adjustedContentInset.bottom ) setContentOffset(CGPoint(x: contentOffset.x, y: targetY), animated: false) - recordViewport() - #if DEBUG - updateDebugAccessibilityValue() - #endif + recordCurrentViewport() } func applyTranscriptViewportInsets( @@ -254,4 +265,10 @@ final class ChatTranscriptUITableView: UITableView { } #endif } + +struct ChatTranscriptTableAnchor { + let id: String + let offsetFromRowTop: CGFloat +} + #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatTerminalCardView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatTerminalCardView.swift index 92be58182d11..3172feec1d02 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatTerminalCardView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatTerminalCardView.swift @@ -93,6 +93,7 @@ public struct ChatTerminalCardView: View { .contentShape(.rect) } .buttonStyle(.plain) + .accessibilityIdentifier("ChatTerminalToggle-\(rowID)") .accessibilityLabel(headerAccessibilityLabel) .accessibilityValue( isExpanded diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatToolUseRowView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatToolUseRowView.swift index db1e8c354d16..2eeb0187a496 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatToolUseRowView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatToolUseRowView.swift @@ -48,6 +48,7 @@ public struct ChatToolUseRowView: View { .contentShape(.rect) } .buttonStyle(.plain) + .accessibilityIdentifier("ChatToolUseToggle-\(rowID)") .accessibilityValue( isExpanded ? String( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/TerminalCommandBlockView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/TerminalCommandBlockView.swift index b86a10541a8f..dd26388a50a9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/TerminalCommandBlockView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/TerminalCommandBlockView.swift @@ -73,28 +73,7 @@ public struct TerminalCommandBlockView: View { .frame(width: 2.5) } } - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - // `.combine` absorbs the inline "more lines" button, so expose the - // toggle as a VoiceOver custom action when the output is collapsible. - .accessibilityActions { - if lines.count > Self.collapseThreshold { - Button( - isExpanded - ? String( - localized: "chat.terminal.collapse.action", - defaultValue: "Show less output", - bundle: .module - ) - : String( - localized: "chat.terminal.expand.action", - defaultValue: "Show all output", - bundle: .module - ), - action: onToggleExpanded - ) - } - } + .accessibilityIdentifier("TerminalCommandBlock-\(block.id)") } private var commandRow: some View { @@ -110,14 +89,13 @@ public struct TerminalCommandBlockView: View { } } + @ViewBuilder private func outputBlock(_ lines: [String]) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - if !isExpanded, lines.count > Self.collapseThreshold { - collapsedOutput(lines) - } else { - outputText(lines) - } + if !isExpanded, lines.count > Self.collapseThreshold { + collapsedOutput(lines) + } else { + ScrollView(.horizontal, showsIndicators: false) { + outputText(lines) } } } @@ -125,7 +103,9 @@ public struct TerminalCommandBlockView: View { @ViewBuilder private func collapsedOutput(_ lines: [String]) -> some View { let hidden = lines.count - Self.collapsedHeadCount - Self.collapsedTailCount - outputText(Array(lines.prefix(Self.collapsedHeadCount))) + ScrollView(.horizontal, showsIndicators: false) { + outputText(Array(lines.prefix(Self.collapsedHeadCount))) + } Button(action: onToggleExpanded) { Text( String( @@ -136,11 +116,23 @@ public struct TerminalCommandBlockView: View { ) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(theme.accent) - .padding(.vertical, 1) + .padding(.vertical, 4) + .frame(minHeight: 28, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) - outputText(Array(lines.suffix(Self.collapsedTailCount))) - .opacity(0.55) + .accessibilityElement(children: .combine) + .accessibilityLabel( + isExpanded + ? String(localized: "chat.terminal.collapse.action", defaultValue: "Show less output", bundle: .module) + : String(localized: "chat.terminal.expand.action", defaultValue: "Show all output", bundle: .module) + ) + .accessibilityIdentifier("TerminalCommandBlockToggle-\(block.id)") + .accessibilityAddTraits(.isButton) + ScrollView(.horizontal, showsIndicators: false) { + outputText(Array(lines.suffix(Self.collapsedTailCount))) + .opacity(0.55) + } } private func outputText(_ lines: [String]) -> some View { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLogDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLogDemoScreen.swift index 75dac3810617..8265b2de4fe6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLogDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLogDemoScreen.swift @@ -17,12 +17,33 @@ struct TerminalLogDemoScreen: View { private static let blocks: [TerminalCommandBlock] = [ TerminalCommandBlock( id: 0, command: "ls -la", - output: "total 24\ndrwxr-xr-x 6 me staff 192 Jun 12 22:10 .\n-rw-r--r-- 1 me staff 42 Jun 12 22:10 README.md\n-rw-r--r-- 1 me staff 1024 Jun 12 22:10 main.swift", + output: [ + "total 96", + "drwxr-xr-x 8 me staff 256 Jun 12 22:10 .", + "-rw-r--r-- 1 me staff 42 Jun 12 22:10 README.md", + "-rw-r--r-- 1 me staff 1024 Jun 12 22:10 main.swift", + "-rw-r--r-- 1 me staff 512 Jun 12 22:10 Package.swift", + "-rw-r--r-- 1 me staff 256 Jun 12 22:10 Tests.swift", + "drwxr-xr-x 4 me staff 128 Jun 12 22:10 Sources", + "drwxr-xr-x 4 me staff 128 Jun 12 22:10 Fixtures", + ].joined(separator: "\n"), exitCode: 0, isRunning: false ), TerminalCommandBlock( id: 1, command: "git status", - output: "On branch feat-ios-chat-ui\nnothing to commit, working tree clean", + output: """ + On branch feat-ios-chat-ui + Your branch is up to date with 'origin/feat-ios-chat-ui'. + + Changes to be committed: + modified: Sources/Transcript/TerminalLogDemoScreen.swift + + Untracked files: + tmp/scroll-proof-before.png + tmp/scroll-proof-after.png + + nothing else to commit + """, exitCode: 0, isRunning: false ), TerminalCommandBlock( @@ -32,19 +53,40 @@ struct TerminalLogDemoScreen: View { ), TerminalCommandBlock( id: 3, command: "cat missing.txt", - output: "cat: missing.txt: No such file or directory", + output: [ + "cat: missing.txt: No such file or directory", + "searched: ./missing.txt", + "searched: ./Fixtures/missing.txt", + "searched: ./Tests/missing.txt", + "hint: regenerate fixtures before rerunning", + ].joined(separator: "\n"), exitCode: 1, isRunning: false ), TerminalCommandBlock( id: 4, command: "npm run dev", - output: "Starting dev server…\nListening on http://localhost:3000", + output: """ + Starting dev server... + Ready in 612ms + Route / warmed in 91ms + Route /handler/sign-in warmed in 87ms + Route /handler/after-sign-in warmed in 82ms + Listening on http://localhost:3000 + """, exitCode: nil, isRunning: true ), TerminalCommandBlock( id: 5, command: "vim notes.md", output: "", exitCode: nil, isRunning: true, isInteractive: true ), - ] + ] + (6...12).map { index in + TerminalCommandBlock( + id: index, + command: "script/check-\(index).sh", + output: (1...(index == 10 ? 24 : 8)).map { "check \(index).\($0): ok" }.joined(separator: "\n"), + exitCode: 0, + isRunning: false + ) + } var body: some View { NavigationStack { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 9426b4ae71bd..3fc96132c68b 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1099,6 +1099,27 @@ final class cmuxUITests: XCTestCase { } } + @MainActor + func testAgentChatExpansionControlsPreserveTranscriptScrollPosition() throws { + let app = launchAgentChatInlinePreviewApp() + let table = app.tables["ChatTranscriptTableView"] + XCTAssertTrue(table.waitForExistence(timeout: 8)) + _ = try waitForTranscriptMetrics(table, timeout: 8) { + $0.frameHeight > 240 && $0.contentHeight > $0.boundsHeight * 1.6 + } + + try assertExpansionTogglePreservesTranscriptPosition( + buttonID: "ChatToolUseToggle-msg-fixture-4", + table: table, + app: app + ) + try assertExpansionTogglePreservesTranscriptPosition( + buttonID: "ChatTerminalToggle-msg-fixture-6", + table: table, + app: app + ) + } + @MainActor func testAgentChatBottomScrollEdgeUnderlapsDeviceBottom() throws { guard #available(iOS 26.0, *) else { @@ -2425,6 +2446,57 @@ final class cmuxUITests: XCTestCase { return lastMetrics } + @MainActor + private func assertExpansionTogglePreservesTranscriptPosition( + buttonID: String, + table: XCUIElement, + app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let button = app.buttons[buttonID] + let deadline = Date().addingTimeInterval(8) + while Date() < deadline, !button.isHittable { + table.swipeDown(velocity: .fast) + RunLoop.current.run(until: Date().addingTimeInterval(0.12)) + } + XCTAssertTrue(button.isHittable, "Expected expansion control \(buttonID) to become hittable", file: file, line: line) + + let before = try waitForTranscriptMetrics( + table, + timeout: 4, + matching: { $0.distanceFromBottom > 180 && $0.contentHeight > $0.boundsHeight * 1.4 }, + file: file, + line: line + ) + button.tap() + let predicate = NSPredicate(format: "value == %@", "Expanded") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) + let result = XCTWaiter.wait(for: [expectation], timeout: 4) + XCTAssertEqual(result, .completed, "Expected \(buttonID) to expand", file: file, line: line) + let after = try waitForTranscriptMetrics( + table, + timeout: 4, + matching: { $0.distanceFromBottom > 120 }, + file: file, + line: line + ) + XCTAssertLessThanOrEqual( + abs(after.visibleTopY - before.visibleTopY), + 120, + "Tapping \(buttonID) must preserve the visible transcript region instead of jumping. before=\(before) after=\(after)", + file: file, + line: line + ) + XCTAssertGreaterThan( + after.distanceFromBottom, + 120, + "Tapping \(buttonID) must leave the transcript away from the live tail. before=\(before) after=\(after)", + file: file, + line: line + ) + } + @MainActor private func focusTextInput(_ element: XCUIElement, in app: XCUIApplication) -> Bool { for _ in 0..<4 {