2 Commits

Author SHA1 Message Date
195e157e1a ios: sidebar swipe 2026-05-04 21:07:55 -07:00
c5dbd12587 ios: unify sidebars 2026-05-04 20:19:58 -07:00
3 changed files with 481 additions and 467 deletions

View File

@@ -22,75 +22,60 @@ enum PhoneRoute: Hashable {
struct SybilPhoneShellView: View { struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = [] @State private var route: PhoneRoute = .draftChat
@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 @State private var composerFocusRequest = 0
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth @State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
@State private var backSwipeOffset: CGFloat = 0 @State private var isSidebarOverlayPresented = false
@State private var backSwipeCompletionOffset: CGFloat = 0 @State private var sidebarSwipeOffset: CGFloat = 0
@State private var backSwipeIsActive = false @State private var sidebarSwipeIsActive = false
@State private var backSwipeIsCompleting = false @State private var sidebarSwipeIsCompleting = false
@State private var backSwipeHasLatched = false @State private var sidebarSwipeHasLatched = false
@State private var sidebarHighlightSelection: SidebarSelection?
@State private var sidebarHighlightClearTask: Task<Void, Never>?
@State private var openingSelectionRequestID: UUID?
private var canRecognizeBackSwipe: Bool { private var canRecognizeSidebarSwipe: Bool {
!path.isEmpty && !backSwipeIsCompleting !isSidebarOverlayPresented && !sidebarSwipeIsCompleting
} }
private var backSwipeVisualOffset: CGFloat { private var sidebarOverlayProgress: CGFloat {
backSwipeOffset + backSwipeCompletionOffset if isSidebarOverlayPresented {
return 1
}
return SidebarOverlaySwipeMetrics.progress(
for: sidebarSwipeOffset,
width: phoneStackWidth
)
}
private var shouldRenderSidebarOverlay: Bool {
isSidebarOverlayPresented ||
sidebarSwipeIsActive ||
sidebarSwipeIsCompleting ||
sidebarOverlayProgress > 0.001
}
private var currentRouteSelection: SidebarSelection? {
switch route {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .draftChat, .draftSearch, .settings:
return nil
}
}
private var highlightedSidebarSelection: SidebarSelection? {
sidebarHighlightSelection ?? currentRouteSelection
} }
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
ZStack(alignment: .topLeading) { phoneStack(width: proxy.size.width)
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.safeAreaInset(edge: .top, spacing: 0) {
phoneRootTopBar
}
.zIndex(0)
if let route = path.last {
SybilPhoneDestinationView(
viewModel: viewModel,
composerFocusRequest: $composerFocusRequest,
route: route,
onRequestBack: requestBack,
onRequestNewChat: startNewChatFromDestination
)
.background(SybilTheme.background)
.offset(x: backSwipeVisualOffset)
.shadow(
color: backSwipeVisualOffset > 0 ? Color.black.opacity(0.34) : Color.clear,
radius: backSwipeVisualOffset > 0 ? 18 : 0,
x: -8,
y: 0
)
.transition(.move(edge: .trailing))
.zIndex(1)
.background {
WorkspaceSwipePanInstaller(
direction: .right,
isEnabled: canRecognizeBackSwipe,
onBegan: { width in
beginBackSwipe(containerWidth: width)
},
onChanged: { translationX, width in
updateBackSwipe(with: translationX, containerWidth: width)
},
onEnded: { translationX, width, velocityX, didFinish in
finishBackSwipe(
translationX: translationX,
containerWidth: width,
velocityX: velocityX,
didFinish: didFinish
)
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { .onAppear {
updatePhoneStackWidth(proxy.size.width) updatePhoneStackWidth(proxy.size.width)
@@ -100,13 +85,8 @@ struct SybilPhoneShellView: View {
} }
} }
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: path.last) .animation(.easeOut(duration: 0.22), value: route)
.onChange(of: path) { _, nextPath in .animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
guard nextPath.isEmpty else {
return
}
resetBackSwipe(animated: false)
}
.onChange(of: scenePhase) { _, nextPhase in .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
case .background: case .background:
@@ -120,8 +100,8 @@ struct SybilPhoneShellView: View {
shouldRefreshOnForeground = false shouldRefreshOnForeground = false
Task { Task {
await viewModel.refreshAfterAppBecameActive( await viewModel.refreshAfterAppBecameActive(
refreshCollections: path.isEmpty, refreshCollections: isSidebarOverlayPresented,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
) )
} }
case .inactive: case .inactive:
@@ -133,16 +113,117 @@ struct SybilPhoneShellView: View {
} }
} }
private var phoneRootTopBar: some View { private func phoneStack(width: CGFloat) -> some View {
HStack { ZStack(alignment: .topLeading) {
phoneWorkspaceLayer
.zIndex(0)
phoneSidebarOverlayLayer(width: width)
.zIndex(1)
}
}
private var phoneWorkspaceLayer: some View {
SybilPhoneDestinationView(
viewModel: viewModel,
composerFocusRequest: $composerFocusRequest,
route: route,
onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay
)
.background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
.allowsHitTesting(!shouldRenderSidebarOverlay)
.background {
sidebarSwipeInstaller
}
}
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
VStack(spacing: 0) {
phoneOverlayTopBar
SybilPhoneSidebarRoot(
viewModel: viewModel,
highlightedSelection: highlightedSidebarSelection,
onSelect: openSidebarSelection,
onRoute: showRouteAndClearSidebarHighlight
)
}
.opacity(sidebarOverlayProgress)
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
.allowsHitTesting(isSidebarOverlayPresented)
.accessibilityHidden(!isSidebarOverlayPresented)
}
private var sidebarSwipeInstaller: some View {
WorkspaceSwipePanInstaller(
direction: .right,
isEnabled: canRecognizeSidebarSwipe,
onBegan: { width in
beginSidebarSwipe(containerWidth: width)
},
onChanged: { translationX, width in
updateSidebarSwipe(with: translationX, containerWidth: width)
},
onEnded: { translationX, width, velocityX, didFinish in
finishSidebarSwipe(
translationX: translationX,
containerWidth: width,
velocityX: velocityX,
didFinish: didFinish
)
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
guard !isSidebarOverlayPresented else {
return nil
}
return {
startNewChatFromDestination()
}
}
private var phoneOverlayTopBar: some View {
HStack(spacing: 12) {
SybilWordmark(size: 21) SybilWordmark(size: 21)
Spacer() Spacer()
Button {
hideSidebarOverlay()
} label: {
Image(systemName: "chevron.right.2")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(SybilTheme.text)
.frame(width: 54, height: 54)
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.fill(SybilTheme.surface.opacity(0.76))
)
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityLabel("Hide conversations")
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.background { .background {
SybilTheme.panelGradient SybilPhoneOverlayBlurBand(edge: .top)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
} }
} }
@@ -151,62 +232,98 @@ struct SybilPhoneShellView: View {
phoneStackWidth = max(width, 1) phoneStackWidth = max(width, 1)
} }
private func requestBack(animateNavigation: Bool = true) {
guard !path.isEmpty, !backSwipeIsCompleting else {
return
}
if animateNavigation {
Task {
await completeBackSwipe(containerWidth: phoneStackWidth, releaseVelocityX: 0)
}
} else {
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
}
}
private func startNewChatFromDestination() { private func startNewChatFromDestination() {
viewModel.startNewChat() viewModel.startNewChat()
composerFocusRequest += 1 composerFocusRequest += 1
replaceTopRoute(with: .draftChat) showRoute(.draftChat)
} }
private func replaceTopRoute(with route: PhoneRoute) { private func showRoute(_ nextRoute: PhoneRoute) {
if path.isEmpty { let update = {
route = nextRoute
}
if isSidebarOverlayPresented {
withAnimation(.easeOut(duration: 0.22)) { withAnimation(.easeOut(duration: 0.22)) {
path = [route] update()
isSidebarOverlayPresented = false
} }
} else { } else {
path[path.index(before: path.endIndex)] = route update()
} }
resetSidebarSwipe(animated: false)
} }
private func popRoute(disablesAnimations: Bool) { private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
let pop = { showRoute(nextRoute)
guard !path.isEmpty else { clearSidebarHighlight()
}
private func showSidebarOverlay() {
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
resetSidebarSwipe(animated: false)
}
private func hideSidebarOverlay() {
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false
}
resetSidebarSwipe(animated: false)
}
private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return
}
let requestID = UUID()
openingSelectionRequestID = requestID
setSidebarHighlight(selection)
Task {
await viewModel.selectForNavigation(selection)
guard openingSelectionRequestID == requestID else {
return return
} }
_ = path.removeLast()
}
if disablesAnimations { showRoute(PhoneRoute.from(selection: selection))
var transaction = Transaction() openingSelectionRequestID = nil
transaction.disablesAnimations = true clearSidebarHighlight(selection, after: .milliseconds(260))
withTransaction(transaction) {
pop()
}
} else {
withAnimation(.easeOut(duration: 0.22)) {
pop()
}
} }
} }
private func beginBackSwipe(containerWidth: CGFloat) { private func setSidebarHighlight(_ selection: SidebarSelection) {
sidebarHighlightClearTask?.cancel()
sidebarHighlightSelection = selection
}
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
sidebarHighlightClearTask?.cancel()
sidebarHighlightClearTask = Task { @MainActor in
try? await Task.sleep(for: delay)
guard !Task.isCancelled,
sidebarHighlightSelection == selection,
openingSelectionRequestID == nil else {
return
}
sidebarHighlightSelection = nil
}
}
private func clearSidebarHighlight() {
sidebarHighlightClearTask?.cancel()
openingSelectionRequestID = nil
sidebarHighlightSelection = nil
}
private func beginSidebarSwipe(containerWidth: CGFloat) {
let update = { let update = {
backSwipeIsActive = true phoneStackWidth = max(containerWidth, 1)
backSwipeHasLatched = false sidebarSwipeIsActive = true
sidebarSwipeHasLatched = false
} }
var transaction = Transaction() var transaction = Transaction()
@@ -214,97 +331,79 @@ struct SybilPhoneShellView: View {
withTransaction(transaction, update) withTransaction(transaction, update)
} }
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) { private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth) let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = BackSwipeMetrics.isLatched( let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: nextOffset, offset: nextOffset,
width: containerWidth, width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched isCurrentlyLatched: sidebarSwipeHasLatched
) )
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { withTransaction(transaction) {
backSwipeOffset = nextOffset phoneStackWidth = max(containerWidth, 1)
backSwipeHasLatched = nextLatched sidebarSwipeOffset = nextOffset
sidebarSwipeHasLatched = nextLatched
} }
} }
private func finishBackSwipe( private func finishSidebarSwipe(
translationX: CGFloat, translationX: CGFloat,
containerWidth: CGFloat, containerWidth: CGFloat,
velocityX: CGFloat, velocityX: CGFloat,
didFinish: Bool didFinish: Bool
) { ) {
guard backSwipeIsActive else { guard sidebarSwipeIsActive else {
resetBackSwipe(animated: false) resetSidebarSwipe(animated: false)
return return
} }
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth) let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = BackSwipeMetrics.isLatched( let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: finalOffset, offset: finalOffset,
width: containerWidth, width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched isCurrentlyLatched: sidebarSwipeHasLatched
) )
updateBackSwipe(with: translationX, containerWidth: containerWidth) updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
if didFinish && BackSwipeMetrics.shouldComplete( if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
offset: finalOffset, offset: finalOffset,
velocityX: velocityX, velocityX: velocityX,
width: containerWidth, width: containerWidth,
isLatched: finalLatched isLatched: finalLatched
) { ) {
Task { completeSidebarSwipe()
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
}
return return
} }
resetBackSwipe(animated: true, velocityX: velocityX) resetSidebarSwipe(animated: true, velocityX: velocityX)
} }
@MainActor private func completeSidebarSwipe() {
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async { guard !sidebarSwipeIsCompleting else {
guard !path.isEmpty else {
resetBackSwipe(animated: false)
return
}
guard !backSwipeIsCompleting else {
return return
} }
backSwipeIsCompleting = true sidebarSwipeIsCompleting = true
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth) withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
withAnimation(
BackSwipeMetrics.springAnimation(
currentOffset: backSwipeOffset,
targetOffset: targetOffset,
velocityX: releaseVelocityX
)
) {
backSwipeCompletionOffset = targetOffset - backSwipeOffset
} }
resetSidebarSwipe(animated: false)
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
} }
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) { private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = backSwipeOffset + backSwipeCompletionOffset let currentOffset = sidebarSwipeOffset
let reset = { let reset = {
backSwipeOffset = 0 sidebarSwipeOffset = 0
backSwipeCompletionOffset = 0 sidebarSwipeIsActive = false
backSwipeIsActive = false sidebarSwipeIsCompleting = false
backSwipeIsCompleting = false sidebarSwipeHasLatched = false
backSwipeHasLatched = false
} }
if animated { if animated {
withAnimation( withAnimation(
BackSwipeMetrics.springAnimation( SidebarOverlaySwipeMetrics.springAnimation(
currentOffset: currentOffset, currentOffset: currentOffset,
targetOffset: 0, targetOffset: 0,
velocityX: velocityX velocityX: velocityX
@@ -318,31 +417,79 @@ struct SybilPhoneShellView: View {
} }
} }
private struct SybilPhoneSidebarRoot: View { private enum SidebarOverlaySwipeMetrics {
@Bindable var viewModel: SybilViewModel static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
@Binding var path: [PhoneRoute] BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
@State private var openingSelection: SidebarSelection? }
@State private var openingRequestID: UUID?
private var highlightedSelection: SidebarSelection? { static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
if let openingSelection { BackSwipeMetrics.progress(for: offset, width: width)
return openingSelection }
}
guard let route = path.last else { static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
return nil BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
} }
switch route { static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
case let .chat(chatID): BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
return .chat(chatID) }
case let .search(searchID):
return .search(searchID) static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
case .draftChat, .draftSearch, .settings: BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
return nil }
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
}
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
(1 - min(max(progress, 0), 1)) * 18
}
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
min(max(progress, 0), 1) * 14
}
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
1 - (min(max(progress, 0), 1) * 0.22)
}
}
private struct SybilPhoneOverlayBlurBand: View {
var edge: VerticalEdge
var body: some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.opacity(0.34)
Rectangle()
.fill(
LinearGradient(
colors: gradientColors,
startPoint: edge == .top ? .top : .bottom,
endPoint: edge == .top ? .bottom : .top
)
)
} }
} }
private var gradientColors: [Color] {
[
Color.black.opacity(0.94),
SybilTheme.background.opacity(0.78),
Color.black.opacity(0)
]
}
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
var highlightedSelection: SidebarSelection?
var onSelect: (SidebarSelection) -> Void
var onRoute: (PhoneRoute) -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
@@ -357,64 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
} }
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { SybilSidebarItemList(
VStack(alignment: .leading, spacing: 8) { viewModel: viewModel,
ProgressView() isSelected: { item in
.tint(SybilTheme.primary) highlightedSelection == item.selection
Text("Loading conversations…") },
.font(.sybil(.footnote)) onSelect: { item in
.foregroundStyle(SybilTheme.textMuted) onSelect(item.selection)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) )
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.sidebarItems) { item in
Button {
open(item.selection)
} label: {
VStack(spacing: 0.0) {
SybilPhoneSidebarRow(item: item)
Divider()
}
}
.buttonStyle(
SybilPhoneSidebarRowButtonStyle(
isHighlighted: highlightedSelection == item.selection
)
)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
} }
.background(SybilTheme.panelGradient) .background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -429,22 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) { HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
clearOpeningSelection() viewModel.openSettings()
showRoute(.settings) onRoute(.settings)
} }
Spacer() Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
clearOpeningSelection()
viewModel.startNewSearch() viewModel.startNewSearch()
showRoute(.draftSearch) onRoute(.draftSearch)
} }
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
clearOpeningSelection()
viewModel.startNewChat() viewModel.startNewChat()
showRoute(.draftChat) onRoute(.draftChat)
} }
} }
.padding(.horizontal, 18) .padding(.horizontal, 18)
@@ -480,121 +576,6 @@ private struct SybilPhoneSidebarRoot: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
} }
private func clearOpeningSelection() {
openingRequestID = nil
openingSelection = nil
}
private func showRoute(_ route: PhoneRoute) {
withAnimation(.easeOut(duration: 0.22)) {
path = [route]
}
}
private func open(_ selection: SidebarSelection) {
guard openingSelection != selection else {
return
}
let requestID = UUID()
openingRequestID = requestID
openingSelection = selection
Task {
await viewModel.selectForNavigation(selection)
guard openingRequestID == requestID else {
return
}
showRoute(PhoneRoute.from(selection: selection))
openingRequestID = nil
openingSelection = nil
}
}
}
private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey {
static let defaultValue = false
}
private extension EnvironmentValues {
var sybilPhoneSidebarRowIsActive: Bool {
get { self[SybilPhoneSidebarRowIsActiveKey.self] }
set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue }
}
}
private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle {
var isHighlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed)
}
}
private struct SybilPhoneSidebarRow: View {
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
var item: SidebarItem
var body: some View {
let leadingWidth = 22.0
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: leadingWidth, height: leadingWidth)
.background(
Rectangle()
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Spacer()
.frame(width: leadingWidth)
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(18.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Rectangle()
.fill(
isHighlighted
? SybilTheme.selectedRowGradient
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
)
)
}
} }
private struct SybilPhoneDestinationView: View { private struct SybilPhoneDestinationView: View {
@@ -602,12 +583,15 @@ private struct SybilPhoneDestinationView: View {
@Binding var composerFocusRequest: Int @Binding var composerFocusRequest: Int
let route: PhoneRoute let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: () -> Void let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
var body: some View { var body: some View {
SybilWorkspaceView( SybilWorkspaceView(
viewModel: viewModel, viewModel: viewModel,
composerFocusRequest: composerFocusRequest, composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack, onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat onRequestNewChat: onRequestNewChat
) )

View File

@@ -4,13 +4,6 @@ import SwiftUI
struct SybilSidebarView: View { struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
private func iconName(for item: SidebarItem) -> String {
switch item.kind {
case .chat: return "message"
case .search: return "globe"
}
}
private func isSelected(_ item: SidebarItem) -> Bool { private func isSelected(_ item: SidebarItem) -> Bool {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection viewModel.draftKind == nil && viewModel.selectedItem == item.selection
} }
@@ -57,112 +50,13 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
} }
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { SybilSidebarItemList(
VStack(alignment: .leading, spacing: 8) { viewModel: viewModel,
ProgressView() isSelected: isSelected,
.tint(SybilTheme.primary) onSelect: { item in
Text("Loading conversations…") viewModel.select(item.selection)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) )
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
viewModel.select(item.selection)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName(for: item))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
} }
.background(SybilTheme.panelGradient) .background(SybilTheme.panelGradient)
@@ -213,6 +107,142 @@ struct SybilSidebarView: View {
} }
} }
struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
var body: some View {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
}
}
struct SybilSidebarRow: View {
var item: SidebarItem
var isSelected: Bool
private var isHighlighted: Bool {
isSelected
}
private var iconName: String {
switch item.kind {
case .chat: return "message"
case .search: return "globe"
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
}
struct SybilSidebarActivityIndicator: View { struct SybilSidebarActivityIndicator: View {
var body: some View { var body: some View {
ProgressView() ProgressView()

View File

@@ -50,7 +50,7 @@ struct SybilWorkspaceView: View {
} }
private var showsCustomWorkspaceNavigation: Bool { private var showsCustomWorkspaceNavigation: Bool {
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl == .back) usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
} }
private var transcriptScrollContextID: String { private var transcriptScrollContextID: String {