2026-02-20 00:09:02 -08:00
|
|
|
import Observation
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
enum PhoneRoute: Hashable {
|
|
|
|
|
case chat(String)
|
|
|
|
|
case search(String)
|
|
|
|
|
case draftChat
|
|
|
|
|
case draftSearch
|
|
|
|
|
case settings
|
|
|
|
|
|
|
|
|
|
static func from(selection: SidebarSelection) -> PhoneRoute {
|
|
|
|
|
switch selection {
|
|
|
|
|
case let .chat(chatID):
|
|
|
|
|
return .chat(chatID)
|
|
|
|
|
case let .search(searchID):
|
|
|
|
|
return .search(searchID)
|
|
|
|
|
case .settings:
|
|
|
|
|
return .settings
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct SybilPhoneShellView: View {
|
|
|
|
|
@Bindable var viewModel: SybilViewModel
|
|
|
|
|
@State private var path: [PhoneRoute] = []
|
2026-05-02 22:18:33 -07:00
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
|
@State private var shouldRefreshOnForeground = false
|
2026-05-02 22:46:25 -07:00
|
|
|
@State private var composerFocusRequest = 0
|
2026-05-03 23:06:39 -07:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
private var canRecognizeBackSwipe: Bool {
|
|
|
|
|
!path.isEmpty && !backSwipeIsCompleting
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var backSwipeVisualOffset: CGFloat {
|
|
|
|
|
backSwipeOffset + backSwipeCompletionOffset
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-05-03 23:06:39 -07:00
|
|
|
GeometryReader { proxy in
|
|
|
|
|
ZStack(alignment: .topLeading) {
|
|
|
|
|
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
|
|
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
|
|
|
phoneRootTopBar
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
2026-05-03 23:06:39 -07:00
|
|
|
.zIndex(0)
|
|
|
|
|
|
|
|
|
|
if let route = path.last {
|
2026-05-02 22:46:25 -07:00
|
|
|
SybilPhoneDestinationView(
|
|
|
|
|
viewModel: viewModel,
|
|
|
|
|
composerFocusRequest: $composerFocusRequest,
|
2026-05-03 23:06:39 -07:00
|
|
|
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
|
2026-05-02 22:46:25 -07:00
|
|
|
)
|
2026-05-03 23:06:39 -07:00
|
|
|
.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)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-03 23:06:39 -07:00
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.onAppear {
|
|
|
|
|
updatePhoneStackWidth(proxy.size.width)
|
|
|
|
|
}
|
|
|
|
|
.onChange(of: proxy.size.width) { _, width in
|
|
|
|
|
updatePhoneStackWidth(width)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
.tint(SybilTheme.primary)
|
2026-05-03 23:06:39 -07:00
|
|
|
.animation(.easeOut(duration: 0.22), value: path.last)
|
|
|
|
|
.onChange(of: path) { _, nextPath in
|
|
|
|
|
guard nextPath.isEmpty else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resetBackSwipe(animated: false)
|
|
|
|
|
}
|
2026-05-02 22:18:33 -07:00
|
|
|
.onChange(of: scenePhase) { _, nextPhase in
|
|
|
|
|
switch nextPhase {
|
|
|
|
|
case .background:
|
|
|
|
|
shouldRefreshOnForeground = true
|
2026-05-03 16:42:49 -07:00
|
|
|
viewModel.markAppInactiveForNetwork()
|
2026-05-02 22:18:33 -07:00
|
|
|
case .active:
|
2026-05-03 16:42:49 -07:00
|
|
|
viewModel.markAppActiveForNetwork()
|
2026-05-02 22:18:33 -07:00
|
|
|
guard shouldRefreshOnForeground else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
shouldRefreshOnForeground = false
|
|
|
|
|
Task {
|
2026-05-03 16:42:49 -07:00
|
|
|
await viewModel.refreshAfterAppBecameActive(
|
2026-05-02 22:18:33 -07:00
|
|
|
refreshCollections: path.isEmpty,
|
|
|
|
|
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
case .inactive:
|
2026-05-03 16:42:49 -07:00
|
|
|
shouldRefreshOnForeground = true
|
|
|
|
|
viewModel.markAppInactiveForNetwork()
|
2026-05-02 22:18:33 -07:00
|
|
|
@unknown default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-03 23:06:39 -07:00
|
|
|
|
|
|
|
|
private var phoneRootTopBar: some View {
|
|
|
|
|
HStack {
|
|
|
|
|
SybilWordmark(size: 21)
|
|
|
|
|
Spacer()
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
.padding(.top, 10)
|
|
|
|
|
.padding(.bottom, 12)
|
|
|
|
|
.background {
|
|
|
|
|
SybilTheme.panelGradient
|
|
|
|
|
.ignoresSafeArea(edges: .top)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updatePhoneStackWidth(_ width: CGFloat) {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func replaceTopRoute(with route: PhoneRoute) {
|
|
|
|
|
if path.isEmpty {
|
|
|
|
|
withAnimation(.easeOut(duration: 0.22)) {
|
|
|
|
|
path = [route]
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
path[path.index(before: path.endIndex)] = route
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func popRoute(disablesAnimations: Bool) {
|
|
|
|
|
let pop = {
|
|
|
|
|
guard !path.isEmpty else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = path.removeLast()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if disablesAnimations {
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.disablesAnimations = true
|
|
|
|
|
withTransaction(transaction) {
|
|
|
|
|
pop()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
withAnimation(.easeOut(duration: 0.22)) {
|
|
|
|
|
pop()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func beginBackSwipe(containerWidth: CGFloat) {
|
|
|
|
|
let update = {
|
|
|
|
|
backSwipeIsActive = true
|
|
|
|
|
backSwipeHasLatched = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.disablesAnimations = true
|
|
|
|
|
withTransaction(transaction, update)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
|
|
|
|
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
|
|
|
|
let nextLatched = BackSwipeMetrics.isLatched(
|
|
|
|
|
offset: nextOffset,
|
|
|
|
|
width: containerWidth,
|
|
|
|
|
isCurrentlyLatched: backSwipeHasLatched
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.disablesAnimations = true
|
|
|
|
|
withTransaction(transaction) {
|
|
|
|
|
backSwipeOffset = nextOffset
|
|
|
|
|
backSwipeHasLatched = nextLatched
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func finishBackSwipe(
|
|
|
|
|
translationX: CGFloat,
|
|
|
|
|
containerWidth: CGFloat,
|
|
|
|
|
velocityX: CGFloat,
|
|
|
|
|
didFinish: Bool
|
|
|
|
|
) {
|
|
|
|
|
guard backSwipeIsActive else {
|
|
|
|
|
resetBackSwipe(animated: false)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
|
|
|
|
let finalLatched = BackSwipeMetrics.isLatched(
|
|
|
|
|
offset: finalOffset,
|
|
|
|
|
width: containerWidth,
|
|
|
|
|
isCurrentlyLatched: backSwipeHasLatched
|
|
|
|
|
)
|
|
|
|
|
updateBackSwipe(with: translationX, containerWidth: containerWidth)
|
|
|
|
|
|
|
|
|
|
if didFinish && BackSwipeMetrics.shouldComplete(
|
|
|
|
|
offset: finalOffset,
|
|
|
|
|
velocityX: velocityX,
|
|
|
|
|
width: containerWidth,
|
|
|
|
|
isLatched: finalLatched
|
|
|
|
|
) {
|
|
|
|
|
Task {
|
|
|
|
|
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetBackSwipe(animated: true, velocityX: velocityX)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
|
|
|
|
|
guard !path.isEmpty else {
|
|
|
|
|
resetBackSwipe(animated: false)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
guard !backSwipeIsCompleting else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backSwipeIsCompleting = true
|
|
|
|
|
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
|
|
|
|
|
|
|
|
|
|
withAnimation(
|
|
|
|
|
BackSwipeMetrics.springAnimation(
|
|
|
|
|
currentOffset: backSwipeOffset,
|
|
|
|
|
targetOffset: targetOffset,
|
|
|
|
|
velocityX: releaseVelocityX
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
backSwipeCompletionOffset = targetOffset - backSwipeOffset
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
|
|
|
|
|
popRoute(disablesAnimations: true)
|
|
|
|
|
resetBackSwipe(animated: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
|
|
|
|
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
|
|
|
|
|
let reset = {
|
|
|
|
|
backSwipeOffset = 0
|
|
|
|
|
backSwipeCompletionOffset = 0
|
|
|
|
|
backSwipeIsActive = false
|
|
|
|
|
backSwipeIsCompleting = false
|
|
|
|
|
backSwipeHasLatched = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if animated {
|
|
|
|
|
withAnimation(
|
|
|
|
|
BackSwipeMetrics.springAnimation(
|
|
|
|
|
currentOffset: currentOffset,
|
|
|
|
|
targetOffset: 0,
|
|
|
|
|
velocityX: velocityX
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
reset()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
reset()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct SybilPhoneSidebarRoot: View {
|
|
|
|
|
@Bindable var viewModel: SybilViewModel
|
|
|
|
|
@Binding var path: [PhoneRoute]
|
2026-05-03 21:42:28 -07:00
|
|
|
@State private var openingSelection: SidebarSelection?
|
|
|
|
|
@State private var openingRequestID: UUID?
|
|
|
|
|
|
|
|
|
|
private var highlightedSelection: SidebarSelection? {
|
|
|
|
|
if let openingSelection {
|
|
|
|
|
return openingSelection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let route = path.last else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch route {
|
|
|
|
|
case let .chat(chatID):
|
|
|
|
|
return .chat(chatID)
|
|
|
|
|
case let .search(searchID):
|
|
|
|
|
return .search(searchID)
|
|
|
|
|
case .draftChat, .draftSearch, .settings:
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
if let errorMessage = viewModel.errorMessage {
|
|
|
|
|
Text(errorMessage)
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.footnote))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.danger)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
|
|
|
|
|
Divider()
|
|
|
|
|
.overlay(SybilTheme.border)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.tint(SybilTheme.primary)
|
|
|
|
|
Text("Loading conversations…")
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.footnote))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.padding(16)
|
|
|
|
|
} else if viewModel.sidebarItems.isEmpty {
|
|
|
|
|
VStack(spacing: 10) {
|
|
|
|
|
Image(systemName: "message.badge")
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.system(size: 20, weight: .medium))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
Text("Start a chat or run your first search.")
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.footnote))
|
2026-02-20 00:09:02 -08:00
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
.padding(16)
|
|
|
|
|
} else {
|
|
|
|
|
ScrollView {
|
2026-05-03 22:11:29 -07:00
|
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
2026-02-20 00:09:02 -08:00
|
|
|
ForEach(viewModel.sidebarItems) { item in
|
2026-05-03 21:42:28 -07:00
|
|
|
Button {
|
|
|
|
|
open(item.selection)
|
|
|
|
|
} label: {
|
2026-05-03 22:11:29 -07:00
|
|
|
VStack(spacing: 0.0) {
|
|
|
|
|
SybilPhoneSidebarRow(item: item)
|
|
|
|
|
Divider()
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-03 21:42:28 -07:00
|
|
|
.buttonStyle(
|
|
|
|
|
SybilPhoneSidebarRowButtonStyle(
|
|
|
|
|
isHighlighted: highlightedSelection == item.selection
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
.contextMenu {
|
|
|
|
|
Button(role: .destructive) {
|
|
|
|
|
Task {
|
|
|
|
|
await viewModel.deleteItem(item.selection)
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Label("Delete", systemImage: "trash")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-03 23:06:39 -07:00
|
|
|
.refreshable {
|
|
|
|
|
await viewModel.refreshVisibleContent(
|
|
|
|
|
refreshCollections: true,
|
|
|
|
|
refreshSelection: false
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
|
|
|
|
.background(SybilTheme.panelGradient)
|
|
|
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
|
|
|
bottomToolbar
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
private var bottomToolbar: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
2026-02-20 00:09:02 -08:00
|
|
|
Divider()
|
|
|
|
|
.overlay(SybilTheme.border)
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
2026-05-03 21:42:28 -07:00
|
|
|
clearOpeningSelection()
|
2026-05-03 23:06:39 -07:00
|
|
|
showRoute(.settings)
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
|
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
2026-05-03 21:42:28 -07:00
|
|
|
clearOpeningSelection()
|
2026-05-02 16:23:00 -07:00
|
|
|
viewModel.startNewSearch()
|
2026-05-03 23:06:39 -07:00
|
|
|
showRoute(.draftSearch)
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
2026-05-03 21:42:28 -07:00
|
|
|
clearOpeningSelection()
|
2026-05-02 16:23:00 -07:00
|
|
|
viewModel.startNewChat()
|
2026-05-03 23:06:39 -07:00
|
|
|
showRoute(.draftChat)
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
.padding(.horizontal, 18)
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
.background(SybilTheme.panelGradient)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
private func toolbarIconButton(
|
|
|
|
|
systemImage: String,
|
|
|
|
|
accessibilityLabel: String,
|
|
|
|
|
isPrimary: Bool = false,
|
|
|
|
|
action: @escaping () -> Void
|
|
|
|
|
) -> some View {
|
|
|
|
|
Button(action: action) {
|
|
|
|
|
Image(systemName: systemImage)
|
|
|
|
|
.font(.system(size: 18, weight: .semibold))
|
|
|
|
|
.foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted)
|
|
|
|
|
.frame(width: 42, height: 42)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
isPrimary
|
|
|
|
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
|
|
|
|
: AnyShapeStyle(SybilTheme.surface.opacity(0.78))
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1)
|
|
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.accessibilityLabel(accessibilityLabel)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-03 21:42:28 -07:00
|
|
|
|
|
|
|
|
private func clearOpeningSelection() {
|
|
|
|
|
openingRequestID = nil
|
|
|
|
|
openingSelection = nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:06:39 -07:00
|
|
|
private func showRoute(_ route: PhoneRoute) {
|
|
|
|
|
withAnimation(.easeOut(duration: 0.22)) {
|
|
|
|
|
path = [route]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-03 23:06:39 -07:00
|
|
|
showRoute(PhoneRoute.from(selection: selection))
|
2026-05-03 21:42:28 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct SybilPhoneSidebarRow: View {
|
2026-05-03 21:42:28 -07:00
|
|
|
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
|
2026-02-20 00:09:02 -08:00
|
|
|
var item: SidebarItem
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-05-03 22:11:29 -07:00
|
|
|
let leadingWidth = 22.0
|
|
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
2026-02-20 00:09:02 -08:00
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.system(size: 12, weight: .semibold))
|
2026-05-03 21:42:28 -07:00
|
|
|
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
2026-05-03 22:11:29 -07:00
|
|
|
.frame(width: leadingWidth, height: leadingWidth)
|
2026-05-02 16:23:00 -07:00
|
|
|
.background(
|
2026-05-03 22:11:29 -07:00
|
|
|
Rectangle()
|
2026-05-03 21:42:28 -07:00
|
|
|
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
2026-05-03 22:11:29 -07:00
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
Text(item.title)
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.subheadline, weight: .semibold))
|
2026-02-20 00:09:02 -08:00
|
|
|
.lineLimit(1)
|
2026-05-04 20:14:16 -07:00
|
|
|
.layoutPriority(1)
|
|
|
|
|
|
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
|
|
|
|
|
if item.isRunning {
|
|
|
|
|
SybilSidebarActivityIndicator()
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HStack(spacing: 8) {
|
2026-05-03 22:11:29 -07:00
|
|
|
Spacer()
|
|
|
|
|
.frame(width: leadingWidth)
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
Text(item.updatedAt.sybilRelativeLabel)
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.caption2))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
|
|
|
|
|
if let initiated = item.initiatedLabel {
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
Text(initiated)
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.caption2))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
|
|
|
.lineLimit(1)
|
2026-05-02 16:23:00 -07:00
|
|
|
.multilineTextAlignment(.trailing)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
2026-05-03 22:11:29 -07:00
|
|
|
.padding(18.0)
|
2026-02-20 00:09:02 -08:00
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.background(
|
2026-05-03 22:11:29 -07:00
|
|
|
Rectangle()
|
2026-05-03 21:42:28 -07:00
|
|
|
.fill(
|
|
|
|
|
isHighlighted
|
|
|
|
|
? SybilTheme.selectedRowGradient
|
|
|
|
|
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
)
|
2026-05-03 22:11:29 -07:00
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct SybilPhoneDestinationView: View {
|
|
|
|
|
@Bindable var viewModel: SybilViewModel
|
2026-05-02 22:46:25 -07:00
|
|
|
@Binding var composerFocusRequest: Int
|
2026-02-20 00:09:02 -08:00
|
|
|
let route: PhoneRoute
|
2026-05-03 23:06:39 -07:00
|
|
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
|
|
|
|
let onRequestNewChat: () -> Void
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-05-03 17:52:57 -07:00
|
|
|
SybilWorkspaceView(
|
|
|
|
|
viewModel: viewModel,
|
2026-05-03 21:14:10 -07:00
|
|
|
composerFocusRequest: composerFocusRequest,
|
2026-05-03 23:06:39 -07:00
|
|
|
onRequestBack: onRequestBack,
|
|
|
|
|
onRequestNewChat: onRequestNewChat
|
2026-05-03 21:14:10 -07:00
|
|
|
)
|
2026-05-02 22:46:25 -07:00
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.task(id: route) {
|
|
|
|
|
applyRoute()
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func applyRoute() {
|
|
|
|
|
switch route {
|
|
|
|
|
case let .chat(chatID):
|
2026-05-03 21:42:28 -07:00
|
|
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
viewModel.select(.chat(chatID))
|
|
|
|
|
case let .search(searchID):
|
2026-05-03 21:42:28 -07:00
|
|
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
viewModel.select(.search(searchID))
|
|
|
|
|
case .draftChat:
|
|
|
|
|
viewModel.startNewChat()
|
|
|
|
|
case .draftSearch:
|
|
|
|
|
viewModel.startNewSearch()
|
|
|
|
|
case .settings:
|
|
|
|
|
viewModel.openSettings()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|