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(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
@State private var composerFocusRequest = 0
|
||||||
|
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
SybilFontRegistry.registerIfNeeded()
|
SybilFontRegistry.registerIfNeeded()
|
||||||
@@ -29,7 +30,10 @@ public struct SplitView: View {
|
|||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SybilSidebarView(viewModel: viewModel)
|
SybilSidebarView(viewModel: viewModel)
|
||||||
} detail: {
|
} detail: {
|
||||||
SybilWorkspaceView(viewModel: viewModel)
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ struct SybilPhoneShellView: View {
|
|||||||
@State private var path: [PhoneRoute] = []
|
@State private var path: [PhoneRoute] = []
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
@State private var composerFocusRequest = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
@@ -37,7 +38,12 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: PhoneRoute.self) { route in
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
SybilPhoneDestinationView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
path: $path,
|
||||||
|
composerFocusRequest: $composerFocusRequest,
|
||||||
|
route: route
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
@@ -248,15 +254,25 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneDestinationView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Binding var path: [PhoneRoute]
|
||||||
|
@Binding var composerFocusRequest: Int
|
||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(viewModel: viewModel)
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
viewModel.startNewChat()
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
composerFocusRequest += 1
|
||||||
.task(id: route) {
|
if path.isEmpty {
|
||||||
applyRoute()
|
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() {
|
private func applyRoute() {
|
||||||
|
|||||||
@@ -6,12 +6,22 @@ import UIKit
|
|||||||
|
|
||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var composerFocusRequest: Int = 0
|
||||||
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@State private var isShowingFileImporter = false
|
@State private var isShowingFileImporter = false
|
||||||
@State private var isShowingPhotoPicker = false
|
@State private var isShowingPhotoPicker = false
|
||||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||||
@State private var isComposerDropTargeted = false
|
@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 {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
@@ -34,7 +44,73 @@ struct SybilWorkspaceView: View {
|
|||||||
return "chat:none"
|
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 {
|
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) {
|
VStack(spacing: 0) {
|
||||||
if showsHeader {
|
if showsHeader {
|
||||||
header
|
header
|
||||||
@@ -67,6 +143,24 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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 {
|
if viewModel.showsComposer {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -74,22 +168,114 @@ struct SybilWorkspaceView: View {
|
|||||||
composerBar
|
composerBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(viewModel.selectedTitle)
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbarRole(.editor)
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
.toolbar {
|
let update = {
|
||||||
if !isSettingsSelected {
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
newChatSwipeIsActive = true
|
||||||
if viewModel.isSearchMode {
|
newChatSwipeHasLatched = false
|
||||||
searchModeChip
|
newChatSwipeDidTriggerHaptic = false
|
||||||
} else {
|
|
||||||
providerModelMenu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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 {
|
private var header: some View {
|
||||||
@@ -286,11 +472,9 @@ struct SybilWorkspaceView: View {
|
|||||||
Button("Files") {
|
Button("Files") {
|
||||||
isShowingFileImporter = true
|
isShowingFileImporter = true
|
||||||
}
|
}
|
||||||
if canPasteFromClipboard {
|
Button("Paste from Clipboard") {
|
||||||
Button("Paste from Clipboard") {
|
Task {
|
||||||
Task {
|
await pasteAttachmentsFromClipboard()
|
||||||
await pasteAttachmentsFromClipboard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
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
|
@MainActor
|
||||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||||
do {
|
do {
|
||||||
@@ -401,6 +571,11 @@ struct SybilWorkspaceView: View {
|
|||||||
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
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)
|
try viewModel.appendComposerAttachments(attachments)
|
||||||
composerFocused = true
|
composerFocused = true
|
||||||
} catch {
|
} 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 Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
@@ -265,3 +266,21 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(snapshot.getSearch == 1)
|
#expect(snapshot.getSearch == 1)
|
||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#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:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||||
- Search sends `POST /v1/searches/:searchId/run/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:
|
Client API contract docs:
|
||||||
- `../docs/api/rest.md`
|
- `../docs/api/rest.md`
|
||||||
|
|||||||
@@ -1008,6 +1008,10 @@ export default function App() {
|
|||||||
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
||||||
return "Sybil";
|
return "Sybil";
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
document.title = pageTitle;
|
document.title = pageTitle;
|
||||||
@@ -1035,6 +1039,56 @@ export default function App() {
|
|||||||
setIsMobileSidebarOpen(false);
|
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) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 160;
|
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}>
|
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New chat
|
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>
|
||||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
New search
|
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>
|
</Button>
|
||||||
<div className="relative">
|
<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" />
|
<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