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 {
@Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = []
@State private var route: PhoneRoute = .draftChat
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
@State private var backSwipeOffset: CGFloat = 0
@State private var backSwipeCompletionOffset: CGFloat = 0
@State private var backSwipeIsActive = false
@State private var backSwipeIsCompleting = false
@State private var backSwipeHasLatched = false
@State private var isSidebarOverlayPresented = false
@State private var sidebarSwipeOffset: CGFloat = 0
@State private var sidebarSwipeIsActive = false
@State private var sidebarSwipeIsCompleting = 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 {
!path.isEmpty && !backSwipeIsCompleting
private var canRecognizeSidebarSwipe: Bool {
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
}
private var backSwipeVisualOffset: CGFloat {
backSwipeOffset + backSwipeCompletionOffset
private var sidebarOverlayProgress: CGFloat {
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 {
GeometryReader { proxy in
ZStack(alignment: .topLeading) {
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)
}
}
}
phoneStack(width: proxy.size.width)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
updatePhoneStackWidth(proxy.size.width)
@@ -100,13 +85,8 @@ struct SybilPhoneShellView: View {
}
}
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: path.last)
.onChange(of: path) { _, nextPath in
guard nextPath.isEmpty else {
return
}
resetBackSwipe(animated: false)
}
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -120,8 +100,8 @@ struct SybilPhoneShellView: View {
shouldRefreshOnForeground = false
Task {
await viewModel.refreshAfterAppBecameActive(
refreshCollections: path.isEmpty,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
refreshCollections: isSidebarOverlayPresented,
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
)
}
case .inactive:
@@ -133,16 +113,117 @@ struct SybilPhoneShellView: View {
}
}
private var phoneRootTopBar: some View {
HStack {
private func phoneStack(width: CGFloat) -> some View {
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)
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(.top, 10)
.padding(.bottom, 12)
.background {
SybilTheme.panelGradient
SybilPhoneOverlayBlurBand(edge: .top)
.ignoresSafeArea(edges: .top)
}
}
@@ -151,62 +232,98 @@ struct SybilPhoneShellView: View {
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() {
viewModel.startNewChat()
composerFocusRequest += 1
replaceTopRoute(with: .draftChat)
showRoute(.draftChat)
}
private func replaceTopRoute(with route: PhoneRoute) {
if path.isEmpty {
private func showRoute(_ nextRoute: PhoneRoute) {
let update = {
route = nextRoute
}
if isSidebarOverlayPresented {
withAnimation(.easeOut(duration: 0.22)) {
path = [route]
update()
isSidebarOverlayPresented = false
}
} else {
path[path.index(before: path.endIndex)] = route
update()
}
resetSidebarSwipe(animated: false)
}
private func popRoute(disablesAnimations: Bool) {
let pop = {
guard !path.isEmpty else {
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
showRoute(nextRoute)
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
}
_ = path.removeLast()
}
if disablesAnimations {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
pop()
}
} else {
withAnimation(.easeOut(duration: 0.22)) {
pop()
}
showRoute(PhoneRoute.from(selection: selection))
openingSelectionRequestID = nil
clearSidebarHighlight(selection, after: .milliseconds(260))
}
}
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 = {
backSwipeIsActive = true
backSwipeHasLatched = false
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeIsActive = true
sidebarSwipeHasLatched = false
}
var transaction = Transaction()
@@ -214,97 +331,79 @@ struct SybilPhoneShellView: View {
withTransaction(transaction, update)
}
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = BackSwipeMetrics.isLatched(
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: nextOffset,
width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched
isCurrentlyLatched: sidebarSwipeHasLatched
)
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
backSwipeOffset = nextOffset
backSwipeHasLatched = nextLatched
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeOffset = nextOffset
sidebarSwipeHasLatched = nextLatched
}
}
private func finishBackSwipe(
private func finishSidebarSwipe(
translationX: CGFloat,
containerWidth: CGFloat,
velocityX: CGFloat,
didFinish: Bool
) {
guard backSwipeIsActive else {
resetBackSwipe(animated: false)
guard sidebarSwipeIsActive else {
resetSidebarSwipe(animated: false)
return
}
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = BackSwipeMetrics.isLatched(
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: finalOffset,
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,
velocityX: velocityX,
width: containerWidth,
isLatched: finalLatched
) {
Task {
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
}
completeSidebarSwipe()
return
}
resetBackSwipe(animated: true, velocityX: velocityX)
resetSidebarSwipe(animated: true, velocityX: velocityX)
}
@MainActor
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
guard !path.isEmpty else {
resetBackSwipe(animated: false)
return
}
guard !backSwipeIsCompleting else {
private func completeSidebarSwipe() {
guard !sidebarSwipeIsCompleting else {
return
}
backSwipeIsCompleting = true
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
withAnimation(
BackSwipeMetrics.springAnimation(
currentOffset: backSwipeOffset,
targetOffset: targetOffset,
velocityX: releaseVelocityX
)
) {
backSwipeCompletionOffset = targetOffset - backSwipeOffset
sidebarSwipeIsCompleting = true
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
resetSidebarSwipe(animated: false)
}
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = sidebarSwipeOffset
let reset = {
backSwipeOffset = 0
backSwipeCompletionOffset = 0
backSwipeIsActive = false
backSwipeIsCompleting = false
backSwipeHasLatched = false
sidebarSwipeOffset = 0
sidebarSwipeIsActive = false
sidebarSwipeIsCompleting = false
sidebarSwipeHasLatched = false
}
if animated {
withAnimation(
BackSwipeMetrics.springAnimation(
SidebarOverlaySwipeMetrics.springAnimation(
currentOffset: currentOffset,
targetOffset: 0,
velocityX: velocityX
@@ -318,31 +417,79 @@ struct SybilPhoneShellView: View {
}
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute]
@State private var openingSelection: SidebarSelection?
@State private var openingRequestID: UUID?
private enum SidebarOverlaySwipeMetrics {
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
}
private var highlightedSelection: SidebarSelection? {
if let openingSelection {
return openingSelection
}
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.progress(for: offset, width: width)
}
guard let route = path.last else {
return nil
}
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
}
switch route {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .draftChat, .draftSearch, .settings:
return nil
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
}
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
}
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 {
VStack(spacing: 0) {
if let errorMessage = viewModel.errorMessage {
@@ -357,64 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
SybilSidebarItemList(
viewModel: viewModel,
isSelected: { item in
highlightedSelection == item.selection
},
onSelect: { item in
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)
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -429,22 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
clearOpeningSelection()
showRoute(.settings)
viewModel.openSettings()
onRoute(.settings)
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
clearOpeningSelection()
viewModel.startNewSearch()
showRoute(.draftSearch)
onRoute(.draftSearch)
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
clearOpeningSelection()
viewModel.startNewChat()
showRoute(.draftChat)
onRoute(.draftChat)
}
}
.padding(.horizontal, 18)
@@ -480,121 +576,6 @@ private struct SybilPhoneSidebarRoot: View {
.buttonStyle(.plain)
.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 {
@@ -602,12 +583,15 @@ private struct SybilPhoneDestinationView: View {
@Binding var composerFocusRequest: Int
let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: () -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
var body: some View {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
)

View File

@@ -4,13 +4,6 @@ import SwiftUI
struct SybilSidebarView: View {
@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 {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
}
@@ -57,112 +50,13 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
SybilSidebarItemList(
viewModel: viewModel,
isSelected: isSelected,
onSelect: { item in
viewModel.select(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: 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)
@@ -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 {
var body: some View {
ProgressView()

View File

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