diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index a253eca..ede49c1 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View { var messages: [Message] var isLoading: Bool var isSending: Bool + var topContentInset: CGFloat = 0 @State private var hasHandledInitialTranscriptScroll = false private var hasPendingAssistant: Bool { @@ -48,7 +49,8 @@ struct SybilChatTranscriptView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.vertical, 18) + .padding(.top, 18 + topContentInset) + .padding(.bottom, 18) } .frame(maxWidth: .infinity, alignment: .leading) .scrollDismissesKeyboard(.interactively) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 90c1527..425991e 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -262,7 +262,11 @@ private struct SybilPhoneDestinationView: View { let route: PhoneRoute var body: some View { - SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) { + SybilWorkspaceView( + viewModel: viewModel, + composerFocusRequest: composerFocusRequest, + usesCustomChatNavigation: route.isChatTranscript + ) { viewModel.startNewChat() composerFocusRequest += 1 if path.isEmpty { @@ -293,3 +297,14 @@ private struct SybilPhoneDestinationView: View { } } } + +private extension PhoneRoute { + var isChatTranscript: Bool { + switch self { + case .chat, .draftChat: + return true + case .search, .draftSearch, .settings: + return false + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 6311e04..bd1f96b 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -7,8 +7,10 @@ import UIKit struct SybilWorkspaceView: View { @Bindable var viewModel: SybilViewModel var composerFocusRequest: Int = 0 + var usesCustomChatNavigation: Bool = false var onRequestNewChat: (() -> Void)? = nil @FocusState private var composerFocused: Bool + @Environment(\.dismiss) private var dismiss @State private var isShowingAttachmentOptions = false @State private var isShowingFileImporter = false @State private var isShowingPhotoPicker = false @@ -23,6 +25,8 @@ struct SybilWorkspaceView: View { @State private var newChatSwipeDidTriggerHaptic = false @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? + private let customChatNavigationContentInset: CGFloat = 88 + private var isSettingsSelected: Bool { if case .settings = viewModel.selectedItem { return true @@ -34,6 +38,10 @@ struct SybilWorkspaceView: View { viewModel.errorMessage != nil } + private var showsCustomChatNavigation: Bool { + usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode + } + private var transcriptScrollContextID: String { if viewModel.draftKind == .chat { return "draft-chat" @@ -84,16 +92,17 @@ struct SybilWorkspaceView: View { } .offset(x: newChatSwipeCompletionOffset) .background(SybilTheme.background) - .navigationTitle(viewModel.selectedTitle) + .navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle) .navigationBarTitleDisplayMode(.inline) .toolbarRole(.editor) + .toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar) .toolbar { - if !isSettingsSelected { + if !isSettingsSelected && !showsCustomChatNavigation { ToolbarItem(placement: .topBarTrailing) { if viewModel.isSearchMode { searchModeChip } else { - providerModelMenu + providerModelToolbarMenu } } } @@ -111,6 +120,16 @@ struct SybilWorkspaceView: View { } private var workspaceContent: some View { + ZStack(alignment: .top) { + workspaceContentStack + + if showsCustomChatNavigation { + customChatNavigationBar + } + } + } + + private var workspaceContentStack: some View { VStack(spacing: 0) { if showsHeader { header @@ -137,7 +156,8 @@ struct SybilWorkspaceView: View { SybilChatTranscriptView( messages: viewModel.displayedMessages, isLoading: viewModel.isLoadingSelection, - isSending: viewModel.isSending + isSending: viewModel.isSending, + topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0 ) .id(transcriptScrollContextID) } @@ -170,6 +190,35 @@ struct SybilWorkspaceView: View { } } + private var customChatNavigationBar: some View { + HStack(spacing: 14) { + Button { + dismiss() + } label: { + SybilNavigationIcon(systemImage: "chevron.left") + } + .buttonStyle(.plain) + .accessibilityLabel("Back") + + Text(viewModel.selectedTitle) + .font(.sybil(size: 16, weight: .semibold)) + .foregroundStyle(SybilTheme.text) + .lineLimit(1) + .minimumScaleFactor(0.78) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + providerModelNavigationMenu + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 34) + .background(alignment: .top) { + SybilNavigationFadeBackground() + .allowsHitTesting(false) + } + } + private func beginNewChatSwipe(containerWidth: CGFloat) { let update = { newChatSwipeContainerWidth = max(containerWidth, 1) @@ -292,34 +341,8 @@ struct SybilWorkspaceView: View { .background(SybilTheme.panelGradient.opacity(0.58)) } - private var providerModelMenu: some View { - Menu { - Text("\(viewModel.provider.displayName) • \(viewModel.model)") - .font(.sybil(.caption)) - - Divider() - - ForEach(Provider.allCases, id: \.self) { candidate in - Menu(candidate.displayName) { - let models = viewModel.modelOptions(for: candidate) - if models.isEmpty { - Text("No models") - } else { - ForEach(models, id: \.self) { candidateModel in - Button { - viewModel.setProvider(candidate, model: candidateModel) - } label: { - if viewModel.provider == candidate && viewModel.model == candidateModel { - Label(candidateModel, systemImage: "checkmark") - } else { - Text(candidateModel) - } - } - } - } - } - } - } label: { + private var providerModelToolbarMenu: some View { + providerModelMenu { Image(systemName: "ellipsis") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(SybilTheme.text) @@ -333,9 +356,52 @@ struct SybilWorkspaceView: View { .stroke(SybilTheme.border.opacity(0.82), lineWidth: 1) ) } + } + + private var providerModelNavigationMenu: some View { + providerModelMenu { + SybilNavigationIcon(systemImage: "ellipsis") + } + } + + private func providerModelMenu(@ViewBuilder label: @escaping () -> Label) -> some View { + Menu { + providerModelMenuItems + } label: { + label() + } .accessibilityLabel("Provider and model") } + @ViewBuilder + private var providerModelMenuItems: some View { + Text("\(viewModel.provider.displayName) • \(viewModel.model)") + .font(.sybil(.caption)) + + Divider() + + ForEach(Provider.allCases, id: \.self) { candidate in + Menu(candidate.displayName) { + let models = viewModel.modelOptions(for: candidate) + if models.isEmpty { + Text("No models") + } else { + ForEach(models, id: \.self) { candidateModel in + Button { + viewModel.setProvider(candidate, model: candidateModel) + } label: { + if viewModel.provider == candidate && viewModel.model == candidateModel { + Label(candidateModel, systemImage: "checkmark") + } else { + Text(candidateModel) + } + } + } + } + } + } + } + private var searchModeChip: some View { Label("Search", systemImage: "globe") .font(.sybil(.caption, weight: .medium)) @@ -868,6 +934,52 @@ private extension UIView { } } +private struct SybilNavigationIcon: View { + var systemImage: String + + var body: some View { + Image(systemName: systemImage) + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .foregroundStyle(SybilTheme.text) + .frame(width: 46, height: 46) + .contentShape(Rectangle()) + .shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0) + } +} + +private struct SybilNavigationFadeBackground: View { + var body: some View { + ZStack(alignment: .topLeading) { + LinearGradient( + colors: [ + SybilTheme.background.opacity(1.0), + SybilTheme.background.opacity(0.90), + SybilTheme.background.opacity(0.80), + SybilTheme.background.opacity(0.80), + SybilTheme.background.opacity(0.28), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + + RadialGradient( + colors: [ + SybilTheme.primary.opacity(0.36), + SybilTheme.primary.opacity(0.10), + Color.clear + ], + center: .topLeading, + startRadius: 6, + endRadius: 210 + ) + .blendMode(.screen) + .offset(x: -44, y: -46) + } + .ignoresSafeArea(edges: .top) + } +} + private struct NewChatSwipeBackdrop: View { var progress: CGFloat var hasLatched: Bool