Compare commits
3 Commits
4b0cc3fbf7
...
94565298d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 94565298d8 | |||
| 7360604136 | |||
| ca6b5e0807 |
@@ -5,6 +5,7 @@ public struct SplitView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
|
||||
@MainActor public init() {
|
||||
SybilFontRegistry.registerIfNeeded()
|
||||
@@ -29,7 +30,10 @@ public struct SplitView: View {
|
||||
NavigationSplitView {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(SybilTheme.primary)
|
||||
|
||||
@@ -25,6 +25,7 @@ struct SybilPhoneShellView: View {
|
||||
@State private var path: [PhoneRoute] = []
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
@@ -37,7 +38,12 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
path: $path,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
route: route
|
||||
)
|
||||
}
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
@@ -248,15 +254,25 @@ private struct SybilPhoneSidebarRow: View {
|
||||
|
||||
private struct SybilPhoneDestinationView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Binding var path: [PhoneRoute]
|
||||
@Binding var composerFocusRequest: Int
|
||||
let route: PhoneRoute
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
applyRoute()
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
path = [.draftChat]
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = .draftChat
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
applyRoute()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRoute() {
|
||||
|
||||
@@ -6,12 +6,22 @@ import UIKit
|
||||
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var composerFocusRequest: Int = 0
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@State private var isShowingFileImporter = false
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
@State private var newChatSwipeOffset: CGFloat = 0
|
||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||
@State private var newChatSwipeIsActive = false
|
||||
@State private var newChatSwipeIsCompleting = false
|
||||
@State private var newChatSwipeHasLatched = false
|
||||
@State private var newChatSwipeDidTriggerHaptic = false
|
||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
@@ -34,7 +44,73 @@ struct SybilWorkspaceView: View {
|
||||
return "chat:none"
|
||||
}
|
||||
|
||||
private var canSwipeToCreateChat: Bool {
|
||||
guard onRequestNewChat != nil else {
|
||||
return false
|
||||
}
|
||||
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
||||
return false
|
||||
}
|
||||
guard case .chat = viewModel.selectedItem else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var canRecognizeNewChatSwipe: Bool {
|
||||
canSwipeToCreateChat && !newChatSwipeIsCompleting
|
||||
}
|
||||
|
||||
private var showsNewChatSwipeBackdrop: Bool {
|
||||
canSwipeToCreateChat || newChatSwipeIsCompleting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
if showsNewChatSwipeBackdrop {
|
||||
NewChatSwipeBackdrop(
|
||||
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
||||
hasLatched: newChatSwipeHasLatched
|
||||
)
|
||||
.padding(.trailing, 18)
|
||||
.padding(.vertical, 20)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
workspaceContent
|
||||
.compositingGroup()
|
||||
.offset(x: newChatSwipeOffset)
|
||||
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
|
||||
}
|
||||
.offset(x: newChatSwipeCompletionOffset)
|
||||
.background(SybilTheme.background)
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onChange(of: canSwipeToCreateChat) { _, isEnabled in
|
||||
guard !isEnabled else {
|
||||
return
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.task(id: composerFocusRequest) {
|
||||
await focusComposerIfRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private var workspaceContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showsHeader {
|
||||
header
|
||||
@@ -67,6 +143,24 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
NewChatSwipePanInstaller(
|
||||
isEnabled: canRecognizeNewChatSwipe,
|
||||
onBegan: { width in
|
||||
beginNewChatSwipe(containerWidth: width)
|
||||
},
|
||||
onChanged: { translationX, width in
|
||||
updateNewChatSwipe(with: translationX, containerWidth: width)
|
||||
},
|
||||
onEnded: { translationX, width, didFinish in
|
||||
finishNewChatSwipe(
|
||||
translationX: translationX,
|
||||
containerWidth: width,
|
||||
didFinish: didFinish
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.showsComposer {
|
||||
Divider()
|
||||
@@ -74,22 +168,114 @@ struct SybilWorkspaceView: View {
|
||||
composerBar
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
newChatSwipeIsActive = true
|
||||
newChatSwipeHasLatched = false
|
||||
newChatSwipeDidTriggerHaptic = false
|
||||
}
|
||||
.background(SybilTheme.background)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction, update)
|
||||
|
||||
if newChatSwipeFeedbackGenerator == nil {
|
||||
newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid)
|
||||
}
|
||||
newChatSwipeFeedbackGenerator?.prepare()
|
||||
}
|
||||
|
||||
private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||
let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||
let wasLatched = newChatSwipeHasLatched
|
||||
let nextLatched = NewChatSwipeMetrics.isLatched(
|
||||
offset: nextOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: newChatSwipeHasLatched
|
||||
)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
newChatSwipeOffset = nextOffset
|
||||
newChatSwipeHasLatched = nextLatched
|
||||
}
|
||||
|
||||
if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic {
|
||||
newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95)
|
||||
newChatSwipeDidTriggerHaptic = true
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) {
|
||||
guard newChatSwipeIsActive else {
|
||||
resetNewChatSwipe(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
||||
|
||||
if didFinish && newChatSwipeHasLatched {
|
||||
Task {
|
||||
await completeNewChatSwipe(containerWidth: containerWidth)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
resetNewChatSwipe(animated: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeNewChatSwipe(containerWidth: CGFloat) async {
|
||||
newChatSwipeIsCompleting = true
|
||||
|
||||
withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) {
|
||||
newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot)
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
|
||||
onRequestNewChat?()
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func resetNewChatSwipe(animated: Bool) {
|
||||
let reset = {
|
||||
newChatSwipeOffset = 0
|
||||
newChatSwipeCompletionOffset = 0
|
||||
newChatSwipeIsActive = false
|
||||
newChatSwipeIsCompleting = false
|
||||
newChatSwipeHasLatched = false
|
||||
newChatSwipeDidTriggerHaptic = false
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
reset()
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
|
||||
newChatSwipeFeedbackGenerator = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func focusComposerIfRequested() async {
|
||||
guard composerFocusRequest > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
await Task.yield()
|
||||
try? await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
||||
return
|
||||
}
|
||||
composerFocused = true
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -286,11 +472,9 @@ struct SybilWorkspaceView: View {
|
||||
Button("Files") {
|
||||
isShowingFileImporter = true
|
||||
}
|
||||
if canPasteFromClipboard {
|
||||
Button("Paste from Clipboard") {
|
||||
Task {
|
||||
await pasteAttachmentsFromClipboard()
|
||||
}
|
||||
Button("Paste from Clipboard") {
|
||||
Task {
|
||||
await pasteAttachmentsFromClipboard()
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -343,20 +527,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var canPasteFromClipboard: Bool {
|
||||
let pasteboard = UIPasteboard.general
|
||||
if pasteboard.hasImages {
|
||||
return true
|
||||
}
|
||||
if let url = pasteboard.url, url.isFileURL {
|
||||
return true
|
||||
}
|
||||
if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||
do {
|
||||
@@ -401,6 +571,11 @@ struct SybilWorkspaceView: View {
|
||||
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
||||
}
|
||||
|
||||
guard !attachments.isEmpty else {
|
||||
viewModel.errorMessage = "Clipboard does not contain a supported attachment."
|
||||
return
|
||||
}
|
||||
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
composerFocused = true
|
||||
} catch {
|
||||
@@ -409,3 +584,356 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatSwipeMetrics {
|
||||
static let referenceWidth: CGFloat = 390
|
||||
static let horizontalActivationDistance: CGFloat = 18
|
||||
static let directionDominanceRatio: CGFloat = 1.22
|
||||
static let minimumLeftwardVelocity: CGFloat = 55
|
||||
static let latchHysteresis: CGFloat = 32
|
||||
static let completionOvershoot: CGFloat = 180
|
||||
static let completionAnimationDuration = 0.24
|
||||
static let completionAnimationDelayMs: UInt64 = 240
|
||||
|
||||
static func maxTravel(for width: CGFloat) -> CGFloat {
|
||||
min(max(width * 0.46, 156), 240)
|
||||
}
|
||||
|
||||
static func latchDistance(for width: CGFloat) -> CGFloat {
|
||||
min(max(width * 0.28, 112), 152)
|
||||
}
|
||||
|
||||
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||
max(min(rawTranslation, 0), -maxTravel(for: width))
|
||||
}
|
||||
|
||||
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
let travel = maxTravel(for: width)
|
||||
guard travel > 0 else {
|
||||
return 0
|
||||
}
|
||||
return min(max(abs(offset) / travel, 0), 1)
|
||||
}
|
||||
|
||||
static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
progress(for: offset, width: width) * 10
|
||||
}
|
||||
|
||||
static func shouldBeginPan(
|
||||
leftwardTravel: CGFloat,
|
||||
verticalTravel: CGFloat,
|
||||
leftwardVelocity: CGFloat,
|
||||
verticalVelocity: CGFloat
|
||||
) -> Bool {
|
||||
guard leftwardTravel > 0 || leftwardVelocity > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftwardTravel >= horizontalActivationDistance,
|
||||
leftwardTravel >= verticalTravel * directionDominanceRatio {
|
||||
return true
|
||||
}
|
||||
|
||||
return leftwardVelocity >= minimumLeftwardVelocity &&
|
||||
leftwardVelocity >= verticalVelocity * directionDominanceRatio
|
||||
}
|
||||
|
||||
static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
|
||||
max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance)
|
||||
}
|
||||
|
||||
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||
let distance = abs(offset)
|
||||
if isCurrentlyLatched {
|
||||
return distance >= latchReleaseDistance(for: width)
|
||||
}
|
||||
return distance >= latchDistance(for: width)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
||||
var isEnabled: Bool
|
||||
var onBegan: (CGFloat) -> Void
|
||||
var onChanged: (CGFloat, CGFloat) -> Void
|
||||
var onEnded: (CGFloat, CGFloat, Bool) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> InstallerView {
|
||||
let view = InstallerView()
|
||||
view.isUserInteractionEnabled = false
|
||||
view.coordinator = context.coordinator
|
||||
context.coordinator.markerView = view
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: InstallerView, context: Context) {
|
||||
context.coordinator.update(
|
||||
isEnabled: isEnabled,
|
||||
onBegan: onBegan,
|
||||
onChanged: onChanged,
|
||||
onEnded: onEnded
|
||||
)
|
||||
context.coordinator.markerView = uiView
|
||||
context.coordinator.installIfPossible()
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
|
||||
coordinator.detach()
|
||||
}
|
||||
|
||||
final class InstallerView: UIView {
|
||||
weak var coordinator: Coordinator?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
coordinator?.markerView = self
|
||||
coordinator?.installIfPossible()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
coordinator?.configureScrollViewFailureRequirements()
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
||||
weak var markerView: UIView?
|
||||
private weak var installedWindow: UIWindow?
|
||||
private let panGesture = UIPanGestureRecognizer()
|
||||
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
||||
|
||||
private var isEnabled = false
|
||||
private var onBegan: (CGFloat) -> Void = { _ in }
|
||||
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
||||
private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in }
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
panGesture.addTarget(self, action: #selector(handlePan(_:)))
|
||||
panGesture.cancelsTouchesInView = true
|
||||
panGesture.delaysTouchesBegan = false
|
||||
panGesture.delaysTouchesEnded = false
|
||||
panGesture.delegate = self
|
||||
}
|
||||
|
||||
func update(
|
||||
isEnabled: Bool,
|
||||
onBegan: @escaping (CGFloat) -> Void,
|
||||
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
||||
onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void
|
||||
) {
|
||||
self.isEnabled = isEnabled
|
||||
self.onBegan = onBegan
|
||||
self.onChanged = onChanged
|
||||
self.onEnded = onEnded
|
||||
panGesture.isEnabled = isEnabled
|
||||
configureScrollViewFailureRequirements()
|
||||
}
|
||||
|
||||
func installIfPossible() {
|
||||
guard let window = markerView?.window else {
|
||||
detach()
|
||||
return
|
||||
}
|
||||
|
||||
guard installedWindow !== window else {
|
||||
configureScrollViewFailureRequirements()
|
||||
return
|
||||
}
|
||||
|
||||
installedWindow?.removeGestureRecognizer(panGesture)
|
||||
window.addGestureRecognizer(panGesture)
|
||||
installedWindow = window
|
||||
configureScrollViewFailureRequirements()
|
||||
}
|
||||
|
||||
func detach() {
|
||||
installedWindow?.removeGestureRecognizer(panGesture)
|
||||
installedWindow = nil
|
||||
preparedScrollRecognizers = []
|
||||
}
|
||||
|
||||
func configureScrollViewFailureRequirements() {
|
||||
guard isEnabled, let markerView, let window = markerView.window else {
|
||||
return
|
||||
}
|
||||
|
||||
let markerFrame = markerView.convert(markerView.bounds, to: window)
|
||||
for scrollView in window.sybilDescendantScrollViews {
|
||||
let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer)
|
||||
guard !preparedScrollRecognizers.contains(recognizerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let scrollFrame = scrollView.convert(scrollView.bounds, to: window)
|
||||
if scrollFrame.intersects(markerFrame) {
|
||||
scrollView.panGestureRecognizer.require(toFail: panGesture)
|
||||
preparedScrollRecognizers.insert(recognizerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard let markerView else {
|
||||
return
|
||||
}
|
||||
|
||||
let width = max(markerView.bounds.width, 1)
|
||||
let translationX = recognizer.translation(in: markerView).x
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
onBegan(width)
|
||||
onChanged(translationX, width)
|
||||
|
||||
case .changed:
|
||||
onChanged(translationX, width)
|
||||
|
||||
case .ended:
|
||||
onEnded(translationX, width, true)
|
||||
|
||||
case .cancelled, .failed:
|
||||
onEnded(translationX, width, false)
|
||||
|
||||
case .possible:
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
onEnded(translationX, width, false)
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
||||
return false
|
||||
}
|
||||
|
||||
return markerView.bounds.contains(touch.location(in: markerView))
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
||||
return false
|
||||
}
|
||||
|
||||
let translation = panGesture.translation(in: markerView)
|
||||
let velocity = panGesture.velocity(in: markerView)
|
||||
return NewChatSwipeMetrics.shouldBeginPan(
|
||||
leftwardTravel: max(-translation.x, 0),
|
||||
verticalTravel: abs(translation.y),
|
||||
leftwardVelocity: max(-velocity.x, 0),
|
||||
verticalVelocity: abs(velocity.y)
|
||||
)
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
var sybilDescendantScrollViews: [UIScrollView] {
|
||||
var scrollViews: [UIScrollView] = []
|
||||
collectSybilScrollViews(into: &scrollViews)
|
||||
return scrollViews
|
||||
}
|
||||
|
||||
func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) {
|
||||
if let scrollView = self as? UIScrollView {
|
||||
scrollViews.append(scrollView)
|
||||
}
|
||||
|
||||
for subview in subviews {
|
||||
subview.collectSybilScrollViews(into: &scrollViews)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
private var clampedProgress: CGFloat {
|
||||
min(max(progress, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
Circle()
|
||||
.fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress)))
|
||||
.frame(width: 176, height: 176)
|
||||
.blur(radius: 44)
|
||||
.offset(x: 38, y: 18)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28),
|
||||
SybilTheme.surface.opacity(0.78)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 8,
|
||||
endRadius: 58
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(
|
||||
color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)),
|
||||
radius: 24,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20),
|
||||
Color.clear,
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34)
|
||||
],
|
||||
center: .center
|
||||
)
|
||||
)
|
||||
.frame(width: 72, height: 72)
|
||||
.blur(radius: 10)
|
||||
|
||||
Image(systemName: hasLatched ? "checkmark" : "plus")
|
||||
.font(.system(size: 31, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.symbolEffect(.bounce, value: hasLatched)
|
||||
|
||||
Image(systemName: "sparkle")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
||||
.offset(x: -26, y: -25)
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface.opacity(0.42))
|
||||
.blur(radius: 16)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
.opacity(clampedProgress)
|
||||
.offset(x: (1 - clampedProgress) * 28)
|
||||
.animation(.easeOut(duration: 0.16), value: hasLatched)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
@@ -265,3 +266,21 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(snapshot.getSearch == 1)
|
||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||
}
|
||||
|
||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||
let width: CGFloat = 390
|
||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||
let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)
|
||||
|
||||
#expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
|
||||
#expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
|
||||
#expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
|
||||
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
|
||||
#expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
|
||||
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
|
||||
#expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
|
||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
|
||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
|
||||
- Composer adapts to the active item:
|
||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
||||
- Keyboard shortcuts:
|
||||
- `Cmd/Ctrl+J`: start a new chat.
|
||||
- `Shift+Cmd/Ctrl+J`: start a new search.
|
||||
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
|
||||
|
||||
Client API contract docs:
|
||||
- `../docs/api/rest.md`
|
||||
|
||||
@@ -1008,6 +1008,10 @@ export default function App() {
|
||||
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
||||
return "Sybil";
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
||||
const primaryShortcutModifier = useMemo(() => {
|
||||
if (typeof navigator === "undefined") return "Ctrl";
|
||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pageTitle;
|
||||
@@ -1035,6 +1039,56 @@ export default function App() {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
|
||||
if (!filteredSidebarItems.length) return;
|
||||
|
||||
setError(null);
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
setSelectedItem((current) => {
|
||||
const currentIndex = current
|
||||
? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
||||
: -1;
|
||||
const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1;
|
||||
const nextIndex =
|
||||
currentIndex < 0
|
||||
? fallbackIndex
|
||||
: Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||
const nextItem = filteredSidebarItems[nextIndex];
|
||||
return { kind: nextItem.kind, id: nextItem.id };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||
if (!hasPrimaryModifier || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "j") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
handleCreateSearch();
|
||||
} else {
|
||||
handleCreateChat();
|
||||
}
|
||||
focusComposer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectAdjacentSidebarItem(event.key === "ArrowUp" ? -1 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [filteredSidebarItems, isAuthenticated]);
|
||||
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
const menuWidth = 160;
|
||||
@@ -1632,10 +1686,16 @@ export default function App() {
|
||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
|
||||
{primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
New search
|
||||
<span className="ml-auto rounded-md border border-violet-100/10 bg-white/[0.035] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/44">
|
||||
Shift {primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||
|
||||
Reference in New Issue
Block a user