Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import CmuxAgentChat
import CmuxMobileSupport
import SwiftUI
#if os(iOS)
import UIKit
#endif

/// The horizontal shortcut row above the composer field.
public struct ChatAccessoryChipRow: View {
private static let scrollEdgeFadeWidth: CGFloat = 34
private static let scrollLeadingHardStopWidth: CGFloat = 14
private static let scrollTrailingFadeWidth: CGFloat = 34

private let agentState: ChatAgentState
private let leadingShortcuts: [ChatAccessoryShortcut]
Expand Down Expand Up @@ -42,34 +46,56 @@ public struct ChatAccessoryChipRow: View {
}

public var body: some View {
HStack(spacing: 10) {
if !displayedLeadingShortcuts.isEmpty {
leadingShortcutCluster
.layoutPriority(2)
.zIndex(1)
}
if !displayedScrollableShortcuts.isEmpty {
scrollableShortcutStrip
}
}
.frame(height: 32)
}

private var leadingShortcutCluster: some View {
HStack(spacing: 6) {
ForEach(displayedLeadingShortcuts) { shortcut in
chip(shortcut)
}
}
.fixedSize(horizontal: true, vertical: false)
}

if !displayedScrollableShortcuts.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(displayedScrollableShortcuts) { shortcut in
chip(shortcut)
}
}
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollContentWidth = width
}
.padding(.horizontal, scrollNeedsEdgeFade ? Self.scrollEdgeFadeWidth : 2)
}
.frame(height: 32)
.layoutPriority(1)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollViewportWidth = width
}
.mask {
scrollEdgeFadeMask
private var scrollableShortcutStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(displayedScrollableShortcuts) { shortcut in
chip(shortcut)
}
}
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollContentWidth = width
}
.padding(.leading, usesLeadingHardStop ? Self.scrollLeadingHardStopWidth : 0)
.padding(.trailing, scrollNeedsTrailingFade ? Self.scrollTrailingFadeWidth : 2)
}
.frame(height: 32)
.layoutPriority(1)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollViewportWidth = width
}
.compositingGroup()
.clipShape(.rect)
.mask {
scrollTrailingFadeMask
}
.overlay(alignment: .leading) {
if usesLeadingHardStop {
scrollLeadingHardStop
}
}
}

private var displayedLeadingShortcuts: [ChatAccessoryShortcut] {
Expand All @@ -96,8 +122,16 @@ public struct ChatAccessoryChipRow: View {
!leadingShortcuts.isEmpty || !shortcuts.isEmpty
}

private var scrollNeedsEdgeFade: Bool {
scrollContentWidth > scrollViewportWidth + 1
private var scrollNeedsTrailingFade: Bool {
scrollContentWidth + scrollLeadingInset + 2 > scrollViewportWidth + 1
}

private var usesLeadingHardStop: Bool {
!displayedLeadingShortcuts.isEmpty
}

private var scrollLeadingInset: CGFloat {
usesLeadingHardStop ? Self.scrollLeadingHardStopWidth : 0
}

private var stopShortcut: ChatAccessoryShortcut {
Expand Down Expand Up @@ -156,6 +190,7 @@ public struct ChatAccessoryChipRow: View {
.contentShape(.capsule)
}
.buttonStyle(.plain)
.fixedSize(horizontal: true, vertical: false)
.accessibilityIdentifier(shortcut.id)
.accessibilityLabel(shortcut.accessibilityLabel ?? shortcut.title)
}
Expand All @@ -170,17 +205,30 @@ public struct ChatAccessoryChipRow: View {
}
}

private var scrollLeadingHardStop: some View {
Rectangle()
.fill(scrollHardStopFill)
.frame(width: Self.scrollLeadingHardStopWidth)
.overlay(alignment: .trailing) {
Rectangle()
.fill(Color.primary.opacity(0.16))
.frame(width: 0.5)
}
.allowsHitTesting(false)
}

private var scrollHardStopFill: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color.black
#endif
}

@ViewBuilder
private var scrollEdgeFadeMask: some View {
if scrollNeedsEdgeFade {
private var scrollTrailingFadeMask: some View {
if scrollNeedsTrailingFade {
HStack(spacing: 0) {
LinearGradient(
colors: [.clear, .black],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: Self.scrollEdgeFadeWidth)

Rectangle()
.fill(.black)

Expand All @@ -189,7 +237,7 @@ public struct ChatAccessoryChipRow: View {
startPoint: .leading,
endPoint: .trailing
)
.frame(width: Self.scrollEdgeFadeWidth)
.frame(width: Self.scrollTrailingFadeWidth)
}
} else {
Rectangle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
get { transcriptHostingController.rootView }
set { transcriptHostingController.rootView = newValue }
}

var composerView: Composer {
get { composerHostingController.rootView }
set { composerHostingController.rootView = newValue }
Expand All @@ -23,6 +22,7 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>

private let keyboardContentView = UIView(frame: .zero)
private let transcriptClipView = UIView(frame: .zero)
private let bottomChromeContainerView = UIView(frame: .zero)
private let composerBackgroundView = UIVisualEffectView(effect: nil)
let transcriptHostingController: UIHostingController<Transcript>
let composerHostingController: UIHostingController<Composer>
Expand Down Expand Up @@ -91,11 +91,16 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
transcriptHostingController.view.translatesAutoresizingMaskIntoConstraints = false
transcriptClipView.addSubview(transcriptHostingController.view)

bottomChromeContainerView.backgroundColor = .clear
bottomChromeContainerView.clipsToBounds = false
bottomChromeContainerView.translatesAutoresizingMaskIntoConstraints = false
keyboardContentView.addSubview(bottomChromeContainerView)

composerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
composerBackgroundView.isUserInteractionEnabled = false
composerBackgroundView.clipsToBounds = true
configureComposerBackground()
keyboardContentView.addSubview(composerBackgroundView)
bottomChromeContainerView.addSubview(composerBackgroundView)

addChild(composerHostingController)
composerHostingController.view.backgroundColor = .clear
Expand All @@ -104,18 +109,14 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
composerHostingController.view.translatesAutoresizingMaskIntoConstraints = false
composerHostingController.view.setContentHuggingPriority(.required, for: .vertical)
composerHostingController.view.setContentCompressionResistancePriority(.required, for: .vertical)
keyboardContentView.addSubview(composerHostingController.view)
bottomChromeContainerView.addSubview(composerHostingController.view)
installLayoutConstraints()

transcriptHostingController.didMove(toParent: self)
composerHostingController.didMove(toParent: self)
updateComposerVisibility()

let observer = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
let observer = NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { [weak self] notification in
guard let transition = MobileKeyboardTransition(notification: notification) else {
return
}
Expand All @@ -131,7 +132,7 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
let transcriptClipTopConstraint = transcriptClipView.topAnchor.constraint(equalTo: keyboardContentView.topAnchor)
let transcriptClipBottomConstraint = transcriptClipView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor)
let transcriptHeightConstraint = transcriptHostingController.view.heightAnchor.constraint(equalToConstant: 0)
let composerBottomConstraint = composerHostingController.view.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor)
let composerBottomConstraint = bottomChromeContainerView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor)
self.composerHeightConstraint = composerHeightConstraint
self.transcriptClipTopConstraint = transcriptClipTopConstraint
self.transcriptClipBottomConstraint = transcriptClipBottomConstraint
Expand All @@ -154,14 +155,19 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
transcriptHostingController.view.topAnchor.constraint(equalTo: transcriptClipView.topAnchor),
transcriptHeightConstraint,

composerBackgroundView.topAnchor.constraint(equalTo: composerHostingController.view.topAnchor),
composerBackgroundView.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor),
composerBackgroundView.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor),
composerBackgroundView.bottomAnchor.constraint(equalTo: keyboardContentView.bottomAnchor),

composerHostingController.view.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor),
composerHostingController.view.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor),
bottomChromeContainerView.topAnchor.constraint(equalTo: composerHostingController.view.topAnchor),
bottomChromeContainerView.leadingAnchor.constraint(equalTo: keyboardContentView.leadingAnchor),
bottomChromeContainerView.trailingAnchor.constraint(equalTo: keyboardContentView.trailingAnchor),
composerBottomConstraint,

composerBackgroundView.topAnchor.constraint(equalTo: bottomChromeContainerView.topAnchor),
composerBackgroundView.leadingAnchor.constraint(equalTo: bottomChromeContainerView.leadingAnchor),
composerBackgroundView.trailingAnchor.constraint(equalTo: bottomChromeContainerView.trailingAnchor),
composerBackgroundView.bottomAnchor.constraint(equalTo: bottomChromeContainerView.bottomAnchor),

composerHostingController.view.leadingAnchor.constraint(equalTo: bottomChromeContainerView.leadingAnchor),
composerHostingController.view.trailingAnchor.constraint(equalTo: bottomChromeContainerView.trailingAnchor),
composerHostingController.view.bottomAnchor.constraint(equalTo: bottomChromeContainerView.bottomAnchor),
composerHeightConstraint,
])
}
Expand Down Expand Up @@ -310,6 +316,7 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
keyboardContentView.layer.removeAllAnimations()
transcriptClipView.layer.removeAllAnimations()
transcriptHostingController.view.layer.removeAllAnimations()
bottomChromeContainerView.layer.removeAllAnimations()
composerBackgroundView.layer.removeAllAnimations()
composerHostingController.view.layer.removeAllAnimations()
}
Expand Down Expand Up @@ -436,12 +443,13 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
scrollEdgeCoordinator.configure(
tableView: tables.first,
owner: self,
composerView: composerHostingController.view
bottomChromeView: bottomChromeContainerView
)
}

private func updateComposerVisibility() {
guard isViewLoaded else { return }
bottomChromeContainerView.isHidden = !showsComposer
composerHostingController.view.isHidden = !showsComposer
composerBackgroundView.isHidden = !showsComposer
updateMeasuredGeometryConstants()
Expand All @@ -457,9 +465,7 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>

func trackedTranscriptTables(in view: UIView) -> [ChatTranscriptUITableView] {
if let table = view as? ChatTranscriptUITableView {
// Stop at the transcript table itself; descending into its own
// cells/hosted row views would re-walk the entire transcript on
// every layout/keyboard geometry update.
// Stop at the transcript table itself to avoid walking hosted rows.
return [table]
}
var tables: [ChatTranscriptUITableView] = []
Expand All @@ -475,16 +481,10 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
) -> Bool {
guard let window = view.window else { return false }
let point = touch.location(in: window)
let transcriptFrame = transcriptHostingController.view.convert(
transcriptHostingController.view.bounds,
to: window
)
let transcriptFrame = transcriptHostingController.view.convert(transcriptHostingController.view.bounds, to: window)
guard transcriptFrame.contains(point) else { return false }
guard !excludedKeyboardDismissFrame.contains(point) else { return false }
let composerFrame = composerHostingController.view.convert(
composerHostingController.view.bounds,
to: window
)
let composerFrame = composerHostingController.view.convert(composerHostingController.view.bounds, to: window)
return !composerFrame.contains(point)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ final class ChatScrollEdgeCoordinator {
func configure(
tableView: ChatTranscriptUITableView?,
owner: UIViewController,
composerView: UIView
bottomChromeView: UIView
) {
configureEdgeEffect(for: tableView)
configureContentScrollView(tableView, owner: owner)
configureBottomInteraction(tableView, composerView: composerView)
configureBottomInteraction(tableView, bottomChromeView: bottomChromeView)
}

func reset() {
Expand Down Expand Up @@ -57,7 +57,7 @@ final class ChatScrollEdgeCoordinator {

private func configureBottomInteraction(
_ tableView: ChatTranscriptUITableView?,
composerView: UIView
bottomChromeView: UIView
) {
if #available(iOS 26.0, *) {
guard let tableView else {
Expand All @@ -71,21 +71,30 @@ final class ChatScrollEdgeCoordinator {
} else {
interaction = UIScrollEdgeElementContainerInteraction()
interaction.edge = .bottom
composerView.addInteraction(interaction)
bottomChromeView.addInteraction(interaction)
bottomInteraction = interaction
}

if bottomInteractionTableView !== tableView {
#if DEBUG
bottomInteractionTableView?.recordBottomEdgeElementContainerRegistration(false)
#endif
interaction.scrollView = tableView
bottomInteractionTableView = tableView
}
#if DEBUG
tableView.recordBottomEdgeElementContainerRegistration(true)
#endif
}
}

private func resetBottomInteraction() {
if let interaction = bottomInteraction {
interaction.view?.removeInteraction(interaction)
}
#if DEBUG
bottomInteractionTableView?.recordBottomEdgeElementContainerRegistration(false)
#endif
bottomInteraction = nil
bottomInteractionTableView = nil
}
Expand Down
Loading
Loading