diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift index 0031e20cff2..addcc82769f 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift @@ -58,6 +58,11 @@ public final class ChatConversationStore { /// non-blocking error surface. Cleared on the next success. public private(set) var lastErrorDescription: String? + /// The row id of the latest optimistic outbound prompt, used by chat + /// surfaces to focus the just-sent message instead of leaving it buried + /// against the composer. + public private(set) var latestOutboundFocusRowID: String? + @ObservationIgnored private var messages: [ChatMessage] = [] @ObservationIgnored private var pending: [ChatPendingOutbound] = [] /// Live, not-yet-committed preview of the agent's in-progress prose for the @@ -244,6 +249,7 @@ public final class ChatConversationStore { createdAt: now(), delivery: queueWhileBusy ? .queued : .sending ) + latestOutboundFocusRowID = ChatTranscriptRow.pendingOutboundRowID(for: item.id) pending.append(item) reproject() guard !queueWhileBusy else { return } @@ -638,6 +644,10 @@ public final class ChatConversationStore { } if let index { let removed = pending.remove(at: index) + let removedRowID = ChatTranscriptRow.pendingOutboundRowID(for: removed.id) + if latestOutboundFocusRowID == removedRowID { + latestOutboundFocusRowID = ChatTranscriptRow.messageRowID(for: message.id) + } if let counter = Self.pendingCounter(removed.id) { maxReconciledCounter = max(maxReconciledCounter ?? counter, counter) } @@ -710,7 +720,11 @@ public final class ChatConversationStore { $0.isReconcilable && $0.text.trimmingCharacters(in: .whitespacesAndNewlines) == command }) { - pending.remove(at: index) + let removed = pending.remove(at: index) + let removedRowID = ChatTranscriptRow.pendingOutboundRowID(for: removed.id) + if latestOutboundFocusRowID == removedRowID { + latestOutboundFocusRowID = ChatTranscriptRow.terminalCommandRowID(for: block.id) + } } } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift index ce50e65c77e..f21862cf63c 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift @@ -26,11 +26,23 @@ public enum ChatTranscriptRow: Identifiable, Sendable, Equatable { case .unreadSeparator: return "unread-separator" case .message(let snapshot): - return "msg-\(snapshot.message.id)" + return Self.messageRowID(for: snapshot.message.id) case .pendingOutbound(let pending): - return "pending-\(pending.id)" + return Self.pendingOutboundRowID(for: pending.id) case .terminalCommand(let block): - return "term-\(block.id)" + return Self.terminalCommandRowID(for: block.id) } } + + public static func messageRowID(for messageID: String) -> String { + "msg-\(messageID)" + } + + public static func pendingOutboundRowID(for pendingID: String) -> String { + "pending-\(pendingID)" + } + + public static func terminalCommandRowID(for blockID: Int) -> String { + "term-\(blockID)" + } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift index 0b03c413c41..1d83e25c764 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift @@ -159,6 +159,10 @@ struct ChatConversationStoreTests { ChatSessionDescriptor(id: "session-1", agentKind: .claude, title: "Test") } + private static func terminalDescriptor() -> ChatSessionDescriptor { + ChatSessionDescriptor(id: "session-1", agentKind: .claude, kind: .terminal, title: "Test") + } + private static func prose( seq: Int, role: ChatRole = .agent, @@ -192,12 +196,13 @@ struct ChatConversationStoreTests { private static func makeStore( source: any ChatEventSource, + descriptor: ChatSessionDescriptor = descriptor(), lastReadSeq: Int? = nil, pageSize: Int = 10, maxWindowCount: Int = 600 ) -> ChatConversationStore { ChatConversationStore( - descriptor: descriptor(), + descriptor: descriptor, source: source, lastReadSeq: lastReadSeq, pageSize: pageSize, @@ -412,6 +417,8 @@ struct ChatConversationStoreTests { } ) #expect(Self.pendingItems(store.rows).first?.text == "gated prompt") + let focusedPendingID = Self.pendingItems(store.rows).first.map { ChatTranscriptRow.pendingOutbound($0).id } + #expect(store.latestOutboundFocusRowID == focusedPendingID) await source.release() await sendTask.value @@ -434,6 +441,40 @@ struct ChatConversationStoreTests { && Self.userProseTexts(store.rows) == ["hello agent"] } ) + let userMessageID = Self.snapshots(store.rows) + .first { $0.message.role == .user }? + .message + .id + #expect(userMessageID != nil) + #expect(store.latestOutboundFocusRowID == userMessageID.map(ChatTranscriptRow.messageRowID(for:))) + } + + @Test("terminal command echo transfers outbound focus to the real command row") + func terminalEchoReconcilesPendingFocus() async { + let source = SilentSendEventSource() + let store = Self.makeStore(source: source, descriptor: Self.terminalDescriptor()) + let runTask = Task { await store.run() } + defer { runTask.cancel() } + + #expect(await TestPoller.waitUntil { store.isConnected }) + await store.send(text: "pwd") + + #expect(await TestPoller.waitUntil { Self.pendingItems(store.rows).first?.delivery == .delivered }) + #expect(store.latestOutboundFocusRowID == "pending-local-1") + + await source.emit(.terminalBlocks([ + TerminalCommandBlock(id: 7, command: "pwd", output: "/tmp\n", exitCode: 0, isRunning: false) + ])) + + #expect( + await TestPoller.waitUntil { + Self.pendingItems(store.rows).isEmpty + && store.rows.contains(.terminalCommand( + TerminalCommandBlock(id: 7, command: "pwd", output: "/tmp\n", exitCode: 0, isRunning: false) + )) + } + ) + #expect(store.latestOutboundFocusRowID == ChatTranscriptRow.terminalCommandRowID(for: 7)) } @Test("send failure marks the pending row failed; retry delivers it") diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift index 0626875c300..966eaa1e43a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift @@ -326,6 +326,7 @@ final class ChatKeyboardTrackingViewController transcriptOverlayGeometry.composerBottomInset = overlayBottomInset } updateTranscriptViewportInsets( + topChromeInset: topChromeOverlayInset(), adjustedBottomInset: overlayBottomInset, composerOverlayBottomInset: overlayBottomInset ) @@ -379,6 +380,15 @@ final class ChatKeyboardTrackingViewController return max(0, ceil(visibleComposerHeight + bottomSafeAreaUnderlap)) } + private func topChromeOverlayInset() -> CGFloat { + guard #available(iOS 26.0, *) else { return 0 } + let safeTop = view.window?.safeAreaInsets.top ?? view.safeAreaInsets.top + // iOS 26's floating navigation chrome is intentionally translucent and + // the chat underlaps it, but transcript focus still needs a usable top + // edge below the pills instead of the raw table origin. + return max(132, ceil(safeTop + 72)) + } + private func updateConstraint(_ constraint: NSLayoutConstraint?, to constant: CGFloat) { guard let constraint, abs(constraint.constant - constant) > 0.5 else { return } constraint.constant = constant @@ -405,13 +415,14 @@ final class ChatKeyboardTrackingViewController } private func updateTranscriptViewportInsets( + topChromeInset: CGFloat, adjustedBottomInset: CGFloat, composerOverlayBottomInset: CGFloat ) { let tables = trackedTranscriptTables(in: transcriptHostingController.view) for tableView in tables { tableView.applyTranscriptViewportInsets( - topChromeInset: 0, + topChromeInset: topChromeInset, adjustedBottomInset: adjustedBottomInset, composerOverlayBottomInset: composerOverlayBottomInset ) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift index 1757b5fe865..f7d13daad58 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift @@ -143,6 +143,7 @@ public struct ChatScreen: View { hasLoadedInitialHistory: store.hasLoadedInitialHistory, initialLoadFailed: store.initialLoadFailed, historyTruncatedAtHead: store.historyTruncatedAtHead, + outboundFocusRowID: store.latestOutboundFocusRowID, actions: rowActions, onReachTop: { Task { await store.loadOlder() } }, onRetryInitialLoad: { Task { await store.retryInitialLoad() } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift index 3f059f3b43e..d93fb59d032 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift @@ -19,6 +19,7 @@ public struct ChatTranscriptListView: View { private let hasLoadedInitialHistory: Bool private let initialLoadFailed: Bool private let historyTruncatedAtHead: Bool + private let outboundFocusRowID: String? private let actions: ChatRowActions private let onReachTop: () -> Void private let onRetryInitialLoad: () -> Void @@ -55,6 +56,7 @@ public struct ChatTranscriptListView: View { hasLoadedInitialHistory: Bool = true, initialLoadFailed: Bool = false, historyTruncatedAtHead: Bool = false, + outboundFocusRowID: String? = nil, actions: ChatRowActions, onReachTop: @escaping () -> Void, onRetryInitialLoad: @escaping () -> Void = {} @@ -66,6 +68,7 @@ public struct ChatTranscriptListView: View { self.hasLoadedInitialHistory = hasLoadedInitialHistory self.initialLoadFailed = initialLoadFailed self.historyTruncatedAtHead = historyTruncatedAtHead + self.outboundFocusRowID = outboundFocusRowID self.actions = actions self.onReachTop = onReachTop self.onRetryInitialLoad = onRetryInitialLoad @@ -85,7 +88,8 @@ public struct ChatTranscriptListView: View { onReachTop: onReachTop, onRetryInitialLoad: onRetryInitialLoad, isAtBottom: $isAtBottom, - scrollToBottomRequest: scrollToBottomRequest + scrollToBottomRequest: scrollToBottomRequest, + outboundFocusRowID: outboundFocusRowID ) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .bottomTrailing) { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 2a48045e584..4adc2e72f18 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -21,6 +21,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { let onRetryInitialLoad: () -> Void @Binding var isAtBottom: Bool let scrollToBottomRequest: Int + let outboundFocusRowID: String? @Environment(\.chatTheme) private var theme @Environment(\.chatMarkdownRenderer) private var markdownRenderer @@ -72,7 +73,8 @@ struct ChatTranscriptTableView: UIViewRepresentable { contentCache: contentCache ), in: tableView, - scrollToBottomRequest: scrollToBottomRequest + scrollToBottomRequest: scrollToBottomRequest, + outboundFocusRowID: outboundFocusRowID ) } @@ -83,6 +85,8 @@ struct ChatTranscriptTableView: UIViewRepresentable { private var agentState: ChatAgentState = .idle private var topRequestKey: String? private var lastScrollToBottomRequest = 0 + private var lastOutboundFocusRowID: String? + private var activeOutboundFocusRowID: String? private var isHandlingLayout = false private weak var tableView: ChatTranscriptUITableView? private var isAtBottom: Binding @@ -111,15 +115,29 @@ struct ChatTranscriptTableView: UIViewRepresentable { fileprivate func update( configuration: ChatTranscriptTableConfiguration, in tableView: ChatTranscriptUITableView, - scrollToBottomRequest: Int + scrollToBottomRequest: Int, + outboundFocusRowID: String? ) { self.configuration = configuration - let nextItems = configuration.makeItems() + let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest + let shouldFocusOutbound = outboundFocusRowID != nil + && outboundFocusRowID != lastOutboundFocusRowID + lastScrollToBottomRequest = scrollToBottomRequest + if shouldScrollToBottom { + activeOutboundFocusRowID = nil + } + if shouldFocusOutbound { + lastOutboundFocusRowID = outboundFocusRowID + activeOutboundFocusRowID = outboundFocusRowID + } + let nextItems = configuration.makeItems( + outboundFocusSpacerHeight: activeOutboundFocusRowID.map { _ in + outboundFocusSpacerHeight(in: tableView) + } + ) let shouldReload = nextItems != items || configuration.expandedIDs != expandedIDs || configuration.agentState != agentState - let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest - lastScrollToBottomRequest = scrollToBottomRequest let wasAtBottom = isAtBottom.wrappedValue || distanceFromBottom(in: tableView) <= chatTranscriptAtBottomThreshold let anchor = firstVisibleAnchor(in: tableView) @@ -127,6 +145,8 @@ struct ChatTranscriptTableView: UIViewRepresentable { guard shouldReload else { if shouldScrollToBottom { scrollToBottom(in: tableView, animated: true) + } else if shouldFocusOutbound, let outboundFocusRowID { + focusOutbound(rowID: outboundFocusRowID, in: tableView, animated: true) } updateBottomState(from: tableView) return @@ -139,7 +159,9 @@ struct ChatTranscriptTableView: UIViewRepresentable { tableView.reloadData() tableView.layoutIfNeeded() - if shouldScrollToBottom || wasAtBottom { + if shouldFocusOutbound, let outboundFocusRowID { + focusOutbound(rowID: outboundFocusRowID, in: tableView, animated: false) + } else if shouldScrollToBottom || wasAtBottom { scrollToBottom(in: tableView, animated: false) } else if let anchor { restore(anchor, in: tableView) @@ -170,6 +192,34 @@ struct ChatTranscriptTableView: UIViewRepresentable { return cell } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + guard items.indices.contains(indexPath.row) else { + return UITableView.automaticDimension + } + switch items[indexPath.row] { + case .bottomAnchor: + return 9 + case .outboundFocusSpacer(let height): + return height + default: + return UITableView.automaticDimension + } + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard items.indices.contains(indexPath.row) else { + return tableView.estimatedRowHeight + } + switch items[indexPath.row] { + case .bottomAnchor: + return 9 + case .outboundFocusSpacer(let height): + return height + default: + return tableView.estimatedRowHeight + } + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { guard let tableView = scrollView as? UITableView else { return } updateBottomState(from: tableView) @@ -198,11 +248,16 @@ struct ChatTranscriptTableView: UIViewRepresentable { defer { isHandlingLayout = false } if tableView.isViewportInsetsExternallyDriven { + if let activeOutboundFocusRowID { + focusOutbound(rowID: activeOutboundFocusRowID, in: tableView, animated: false) + } updateBottomState(from: tableView) return } - if boundsChanged, let oldViewport { + if let activeOutboundFocusRowID { + focusOutbound(rowID: activeOutboundFocusRowID, in: tableView, animated: false) + } else if boundsChanged, let oldViewport { restoreKeyboardViewport(snapshot: oldViewport, in: tableView) } else if isAtBottom.wrappedValue { scrollToBottom(in: tableView, animated: false) @@ -240,6 +295,32 @@ struct ChatTranscriptTableView: UIViewRepresentable { setAtBottom(true) } + private func focusOutbound(rowID: String, in tableView: UITableView, animated: Bool) { + guard let row = items.firstIndex(where: { $0.id == rowID }) else { return } + tableView.layoutIfNeeded() + let indexPath = IndexPath(row: row, section: 0) + let rect = tableView.rectForRow(at: indexPath) + let visibleTopPadding: CGFloat = 36 + let chromeInset = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0 + let targetRowY = max(0, chromeInset) + visibleTopPadding + let targetY = clampedOffsetY( + rect.minY - targetRowY, + in: tableView + ) + tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: animated) + setAtBottom(false) + } + + private func outboundFocusSpacerHeight(in tableView: UITableView) -> CGFloat { + let viewportHeight = max( + 0, + tableView.bounds.height + - tableView.adjustedContentInset.top + - tableView.adjustedContentInset.bottom + ) + return max(260, viewportHeight * 1.05) + } + private func requestOlderHistoryIfNeeded(in tableView: UITableView) { guard let configuration, configuration.hasMoreHistory else { topRequestKey = nil @@ -340,7 +421,7 @@ private struct ChatTranscriptTableConfiguration { let markdownRenderer: ChatMarkdownRenderer? let contentCache: ChatContentCache? - func makeItems() -> [ChatTranscriptTableItem] { + func makeItems(outboundFocusSpacerHeight: CGFloat? = nil) -> [ChatTranscriptTableItem] { var items: [ChatTranscriptTableItem] = [] if hasMoreHistory { items.append(.loadingMore) @@ -361,6 +442,9 @@ private struct ChatTranscriptTableConfiguration { items.append(.typing) } items.append(.bottomAnchor) + if let outboundFocusSpacerHeight { + items.append(.outboundFocusSpacer(height: outboundFocusSpacerHeight)) + } return items } @@ -444,6 +528,9 @@ private struct ChatTranscriptTableConfiguration { case .bottomAnchor: Color.clear .frame(height: 9) + case .outboundFocusSpacer(let height): + Color.clear + .frame(height: height) } } } @@ -457,6 +544,7 @@ private enum ChatTranscriptTableItem: Equatable { case row(ChatTranscriptRow) case typing case bottomAnchor + case outboundFocusSpacer(height: CGFloat) var id: String { switch self { @@ -476,6 +564,8 @@ private enum ChatTranscriptTableItem: Equatable { return "typing" case .bottomAnchor: return "bottom-anchor" + case .outboundFocusSpacer: + return "outbound-focus-spacer" } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift index 7f900e21f11..90191fb3726 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift @@ -245,13 +245,14 @@ final class ChatTranscriptUITableView: UITableView { ) } - private func presentationFrameInWindow() -> CGRect? { + #endif + + func presentationFrameInWindow() -> CGRect? { guard let window else { return nil } let sourceLayer = layer.presentation() ?? layer let targetLayer = window.layer.presentation() ?? window.layer return sourceLayer.convert(bounds, to: targetLayer) } - #endif } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift index 1c07b2de0a7..9c4b4b5ca7c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift @@ -1,17 +1,14 @@ import CmuxAgentChat import SwiftUI -/// The single in-place "agent is working" indicator: three animated dots -/// plus live elapsed time. +/// The single in-place "agent is working" indicator: a small composing bloom +/// at the transcript tail. /// /// Exactly one instance renders at the transcript tail while the agent /// works (product rule: working state never spams transcript rows). public struct ChatTypingIndicatorView: View { private let agentState: ChatAgentState - @Environment(\.chatTheme) private var theme - @Environment(\.chatBubbleMaxWidth) private var bubbleMaxWidth - /// Creates the indicator. /// /// - Parameter agentState: The live agent state; renders content only @@ -21,77 +18,138 @@ public struct ChatTypingIndicatorView: View { } public var body: some View { - if case .working(let since) = agentState { - HStack(spacing: 8) { - ChatTypingDotsView() - TimelineView(.periodic(from: since, by: 1)) { context in - Text( - Self.elapsedLabel( - seconds: max(0, Int(context.date.timeIntervalSince(since))) - ) + if case .working = agentState { + ChatThinkingBloomView() + .frame(width: 34, height: 34) + .padding(.leading, 2) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel( + String( + localized: "chat.typing.accessibility", + defaultValue: "Agent is working", + bundle: .module ) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - .contentTransition(.numericText()) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(theme.incomingBubbleFill, in: .rect(cornerRadius: theme.bubbleCornerRadius)) - .frame(maxWidth: typingBubbleMaxWidth, alignment: .leading) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityLabel( - String( - localized: "chat.typing.accessibility", - defaultValue: "Agent is working", - bundle: .module ) - ) } } +} - private var typingBubbleMaxWidth: CGFloat { - bubbleMaxWidth.isFinite ? min(bubbleMaxWidth, 200) : 200 - } +/// A compact, asymmetric mark that reads as composition instead of waiting. +struct ChatThinkingBloomView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Formats elapsed working time compactly ("5s", "1m 23s", "1h 2m"), - /// localized through `Duration`'s units format style. - static func elapsedLabel(seconds: Int) -> String { - let duration = Duration.seconds(seconds) - if seconds < 60 { - return duration.formatted(.units(allowed: [.seconds], width: .narrow)) - } - if seconds < 3600 { - return duration.formatted(.units(allowed: [.minutes, .seconds], width: .narrow)) + var body: some View { + if reduceMotion { + ChatThinkingBloomFrame(phase: 0.18) + } else { + TimelineView(.animation) { timeline in + ChatThinkingBloomFrame( + phase: timeline.date.timeIntervalSinceReferenceDate + ) + } } - return duration.formatted(.units(allowed: [.hours, .minutes], width: .narrow)) } } -/// The three-dot pulse animation inside the typing indicator. -struct ChatTypingDotsView: View { - @State private var animating = false +private struct ChatThinkingBloomFrame: View { + let phase: TimeInterval - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.chatTheme) private var theme var body: some View { - HStack(spacing: 4) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(.secondary) - .frame(width: 7, height: 7) - .opacity(reduceMotion || animating ? 1 : 0.3) - .animation( - reduceMotion - ? nil - : .easeInOut(duration: 0.6) - .repeatForever(autoreverses: true) - .delay(Double(index) * 0.2), - value: animating - ) + Canvas { context, size in + let side = min(size.width, size.height) + let center = CGPoint(x: size.width * 0.48, y: size.height * 0.50) + let cycle = phase * 1.55 + let turn = cycle * 0.82 + let breath = 0.5 + 0.5 * sin(cycle * 2.0) + + for (index, spoke) in Self.spokes.enumerated() { + let ordinal = Double(index) + let lift = 0.5 + 0.5 * sin(cycle * 2.3 + ordinal * 0.86) + let angle = spoke.angle + turn + sin(cycle + ordinal) * 0.10 + let inner = side * (0.12 + 0.015 * lift) + let outer = inner + side * spoke.length * (0.82 + 0.22 * lift) + let opacity = 0.14 + 0.50 * lift + let width = side * (0.038 + 0.010 * lift) + + context.stroke( + radialStroke(center: center, angle: angle, inner: inner, outer: outer), + with: .color(theme.accent.opacity(opacity)), + style: StrokeStyle(lineWidth: width, lineCap: .round) + ) } + + let glintAngle = turn * 1.7 + sin(cycle * 1.3) * 0.45 + let glintRadius = side * (0.28 + 0.05 * breath) + let glint = CGPoint( + x: center.x + cos(glintAngle) * glintRadius, + y: center.y + sin(glintAngle) * glintRadius + ) + let wake = CGPoint( + x: center.x + cos(glintAngle - 0.50) * side * 0.18, + y: center.y + sin(glintAngle - 0.50) * side * 0.18 + ) + context.stroke( + curvedWake(from: wake, through: center, to: glint), + with: .color(theme.accent.opacity(0.22 + 0.18 * breath)), + style: StrokeStyle(lineWidth: side * 0.020, lineCap: .round, lineJoin: .round) + ) + context.fill( + Circle().path(in: CGRect( + x: glint.x - side * 0.040, + y: glint.y - side * 0.040, + width: side * 0.080, + height: side * 0.080 + )), + with: .color(.primary.opacity(0.62)) + ) + context.fill( + Circle().path(in: CGRect( + x: center.x - side * 0.032, + y: center.y - side * 0.032, + width: side * 0.064, + height: side * 0.064 + )), + with: .color(theme.accent.opacity(0.36 + 0.26 * breath)) + ) + } + } + + private static let spokes: [(angle: Double, length: CGFloat)] = [ + (-1.57, 0.22), + (-0.74, 0.18), + (-0.06, 0.25), + (0.66, 0.16), + (1.34, 0.22), + (2.10, 0.14), + (2.82, 0.24), + (3.56, 0.17), + ] + + private func radialStroke( + center: CGPoint, + angle: Double, + inner: CGFloat, + outer: CGFloat + ) -> Path { + Path { path in + path.move(to: CGPoint( + x: center.x + cos(angle) * inner, + y: center.y + sin(angle) * inner + )) + path.addLine(to: CGPoint( + x: center.x + cos(angle) * outer, + y: center.y + sin(angle) * outer + )) + } + } + + private func curvedWake(from start: CGPoint, through control: CGPoint, to end: CGPoint) -> Path { + Path { path in + path.move(to: start) + path.addQuadCurve(to: end, control: control) } - .onAppear { animating = true } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index 6aee369aeae..ab912c37346 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -1,4 +1,5 @@ public import CmuxAgentChat +import CmuxMobileDiagnostics public import CmuxMobileRPC import Foundation @@ -123,6 +124,7 @@ public actor MobileChatEventSource: ChatEventSource { } public func history(sessionID: String, beforeSeq: Int?, limit: Int) async throws -> ChatHistoryPage { + MobileDebugLog.anchormux("agentChat.history.start session=\(Self.short(sessionID)) before=\(beforeSeq.map(String.init) ?? "nil") limit=\(limit)") var params: [String: Any] = [ "session_id": sessionID, "limit": limit, @@ -132,7 +134,9 @@ public actor MobileChatEventSource: ChatEventSource { } let request = try MobileCoreRPCClient.requestData(method: "mobile.chat.history", params: params) let result = try await client.sendRequest(request) - return try coding.decode(ChatHistoryPage.self, from: result) + let page = try coding.decode(ChatHistoryPage.self, from: result) + MobileDebugLog.anchormux("agentChat.history.end session=\(Self.short(sessionID)) messages=\(page.messages.count) blocks=\(page.terminalBlocks?.count ?? 0) hasMore=\(page.hasMore)") + return page } public func events(sessionID: String) async -> AsyncStream { @@ -140,6 +144,7 @@ public actor MobileChatEventSource: ChatEventSource { let client = self.client let coding = self.coding let streamID = UUID().uuidString + MobileDebugLog.anchormux("agentChat.events.localSubscribe session=\(Self.short(sessionID)) stream=\(Self.short(streamID))") return AsyncStream { continuation in let pump = Task { // Server-side handshake after the local listener exists so no @@ -156,7 +161,9 @@ public actor MobileChatEventSource: ChatEventSource { ] ) _ = try await client.sendRequest(subscribe) + MobileDebugLog.anchormux("agentChat.events.serverSubscribe.ok session=\(Self.short(sessionID)) stream=\(Self.short(streamID))") } catch { + MobileDebugLog.anchormux("agentChat.events.serverSubscribe.failed session=\(Self.short(sessionID)) error=\(error.localizedDescription)") continuation.finish() return } @@ -166,6 +173,7 @@ public actor MobileChatEventSource: ChatEventSource { continue } guard frame.sessionID == sessionID else { continue } + MobileDebugLog.anchormux("agentChat.events.frame session=\(Self.short(sessionID)) \(Self.eventSummary(frame.event))") continuation.yield(frame.event) } continuation.finish() @@ -192,6 +200,7 @@ public actor MobileChatEventSource: ChatEventSource { } public func send(text: String, attachments: [ChatOutboundAttachment], sessionID: String) async throws { + MobileDebugLog.anchormux("agentChat.send.start session=\(Self.short(sessionID)) textLen=\(text.count) attachments=\(attachments.count)") var params: [String: Any] = [ "session_id": sessionID, "text": text, @@ -206,6 +215,7 @@ public actor MobileChatEventSource: ChatEventSource { } let request = try MobileCoreRPCClient.requestData(method: "mobile.chat.send", params: params) _ = try await client.sendRequest(request) + MobileDebugLog.anchormux("agentChat.send.end session=\(Self.short(sessionID))") } public func interrupt(sessionID: String, hard: Bool) async throws { @@ -229,4 +239,45 @@ public actor MobileChatEventSource: ChatEventSource { ) _ = try await client.sendRequest(request) } + + private nonisolated static func short(_ value: String) -> String { + String(value.prefix(8)) + } + + private nonisolated static func eventSummary(_ event: ChatSessionEvent) -> String { + switch event { + case .appended(let messages): + return "event=appended messages=\(messages.count) roles=\(roleSummary(messages)) ids=\(messageIDSummary(messages))" + case .updated(let messages): + return "event=updated messages=\(messages.count) roles=\(roleSummary(messages)) ids=\(messageIDSummary(messages))" + case .stateChanged(let state): + return "event=stateChanged state=\(state)" + case .descriptorChanged(let descriptor): + return "event=descriptorChanged kind=\(descriptor.kind) state=\(descriptor.state) version=\(descriptor.version)" + case .terminalBlocks(let blocks): + return "event=terminalBlocks blocks=\(blocks.count) ids=\(blockIDSummary(blocks)) running=\(blockRunningSummary(blocks))" + case .streamingProse(let message): + return "event=streamingProse hasMessage=\(message != nil)" + case .reset: + return "event=reset" + case .unknown(let raw): + return "event=unknown raw=\(raw)" + } + } + + private nonisolated static func roleSummary(_ messages: [ChatMessage]) -> String { + messages.map { "\($0.role)" }.joined(separator: ",") + } + + private nonisolated static func messageIDSummary(_ messages: [ChatMessage]) -> String { + messages.map { short($0.id) }.joined(separator: ",") + } + + private nonisolated static func blockIDSummary(_ blocks: [TerminalCommandBlock]) -> String { + blocks.map { String($0.id) }.joined(separator: ",") + } + + private nonisolated static func blockRunningSummary(_ blocks: [TerminalCommandBlock]) -> String { + blocks.map { $0.isRunning ? "1" : "0" }.joined(separator: ",") + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index 8ab6ea98208..15afae50e44 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -12,6 +12,7 @@ struct AgentChatDemoScreen: View { @Environment(\.dismiss) private var dismiss @State private var stack: DemoStack? + @State private var draft = "" @State private var contentWidth: CGFloat = 0 init(style: AgentChatDemoScreenStyle = .standalone) { @@ -88,6 +89,7 @@ struct AgentChatDemoScreen: View { private func baseChatScreen(for stack: DemoStack) -> some View { ChatScreen( store: stack.store, + draft: $draft, providesOwnChrome: false, onOpenTerminal: {} ) diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 5409fb9b28d..5c0db856d04 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -236,21 +236,33 @@ extension TerminalController { } let text = v2RawString(params, "text") ?? "" let attachments = params["attachments"] as? [[String: Any]] ?? [] + #if DEBUG + cmuxDebugLog("mobile.chat.send.start session=\(sessionID.prefix(8)) textLen=\(text.count) attachments=\(attachments.count)") + #endif guard !text.isEmpty || !attachments.isEmpty else { return .err(code: "invalid_params", message: "Nothing to send", data: nil) } guard let terminalParams = await mobileChatTerminalParams(sessionID: sessionID) else { + #if DEBUG + cmuxDebugLog("mobile.chat.send.terminalParams.failed session=\(sessionID.prefix(8))") + #endif return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) } guard let terminalPanel = await mobileChatTerminalPanel(sessionID: sessionID) else { + #if DEBUG + cmuxDebugLog("mobile.chat.send.terminalPanel.failed session=\(sessionID.prefix(8))") + #endif return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) } let clearResult = mobileChatClearPrompt(terminalPanel) guard clearResult.accepted else { + #if DEBUG + cmuxDebugLog("mobile.chat.send.clear.failed session=\(sessionID.prefix(8))") + #endif return mobileChatInputError(clearResult) } for (index, attachment) in attachments.enumerated() { @@ -289,11 +301,18 @@ extension TerminalController { // agent's prompt; submit it so the send actually reaches the // agent instead of idling in the line editor. let keyResult = terminalPanel.sendNamedKeyResult("return") + #if DEBUG + cmuxDebugLog("mobile.chat.send.attachmentOnly submitted=\(keyResult.accepted) session=\(sessionID.prefix(8))") + #endif return .ok(["submitted": keyResult.accepted]) } var pasteParams = terminalParams pasteParams["text"] = text - return v2MobileTerminalPaste(params: pasteParams) + let result = v2MobileTerminalPaste(params: pasteParams) + #if DEBUG + cmuxDebugLog("mobile.chat.send.paste.result session=\(sessionID.prefix(8)) result=\(result)") + #endif + return result } /// Clears any stale text already sitting in the agent's terminal prompt diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 9426b4ae71b..776c88456f6 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -632,6 +632,83 @@ final class cmuxUITests: XCTestCase { } } + @MainActor + func testAgentChatSendFocusesOutgoingBubbleNearTop() throws { + let app = launchAgentChatInlinePreviewApp() + let table = app.tables["ChatTranscriptTableView"] + XCTAssertTrue(table.waitForExistence(timeout: 8)) + let composerBar = app.otherElements["ChatComposerBar"] + XCTAssertTrue(composerBar.waitForExistence(timeout: 8)) + let composerField = chatComposerField(in: app) + XCTAssertTrue(composerField.waitForExistence(timeout: 8)) + + XCTAssertTrue(tapChatComposerField(composerField, composerBar: composerBar, in: app)) + composerField.typeText("focus proof") + let sendButton = app.buttons["ChatComposerSend"] + XCTAssertTrue(sendButton.waitForExistence(timeout: 4)) + sendButton.tap() + + let sentText = app.staticTexts["focus proof"].firstMatch + XCTAssertTrue(sentText.waitForExistence(timeout: 4)) + let focusedMetrics = try waitForTranscriptMetrics(table, timeout: 4) { metrics in + let visibleTopY = table.frame.minY + max(metrics.adjustedTopInset, metrics.topChromeOverlayInset) + let visibleBottomY = min( + metrics.composerPresentationMinY, + table.frame.maxY - metrics.composerOverlayBottomInset + ) + let focusBandHeight = max(1, visibleBottomY - visibleTopY) + let sentTopOffset = sentText.frame.minY - visibleTopY + let sentBottomGap = visibleBottomY - sentText.frame.maxY + return metrics.topChromeOverlayInset > 100 + && !metrics.keyboardAnimationActive + && metrics.keyboardAnimationProgress >= 0.99 + && sentTopOffset >= 24 + && sentTopOffset <= max(140, focusBandHeight * 0.72) + && sentBottomGap >= 24 + && metrics.distanceFromBottom > 120 + } + let visibleTopY = table.frame.minY + max( + focusedMetrics.adjustedTopInset, + focusedMetrics.topChromeOverlayInset + ) + let visibleBottomY = min( + focusedMetrics.composerPresentationMinY, + table.frame.maxY - focusedMetrics.composerOverlayBottomInset + ) + let focusBandHeight = max(1, visibleBottomY - visibleTopY) + let sentTopOffset = sentText.frame.minY - visibleTopY + let sentBottomGap = visibleBottomY - sentText.frame.maxY + XCTAssertGreaterThan( + focusedMetrics.topChromeOverlayInset, + 100, + "Sent bubble focus must account for floating top chrome. metrics=\(focusedMetrics)" + ) + XCTAssertGreaterThanOrEqual( + sentTopOffset, + 24, + "Sent bubble should not hide under the floating top chrome. offset=\(sentTopOffset) metrics=\(focusedMetrics)" + ) + XCTAssertFalse( + focusedMetrics.keyboardAnimationActive, + "Sent bubble focus should be verified after keyboard animation settles. metrics=\(focusedMetrics)" + ) + XCTAssertLessThanOrEqual( + sentTopOffset, + max(140, focusBandHeight * 0.72), + "Sent bubble should be focused near the transcript top. offset=\(sentTopOffset) metrics=\(focusedMetrics)" + ) + XCTAssertGreaterThanOrEqual( + sentBottomGap, + 24, + "Sent bubble should remain clear of the composer and keyboard. gap=\(sentBottomGap) metrics=\(focusedMetrics)" + ) + XCTAssertGreaterThan( + focusedMetrics.distanceFromBottom, + 120, + "Sent bubble focus should leave breathing room below the outgoing message. metrics=\(focusedMetrics)" + ) + } + @MainActor func testAgentChatMiddleKeyboardVideoEvidence() throws { let app = launchAgentChatInlinePreviewApp(environment: [