From c5dbd12587230255d6fe2fee9de2fe3e5d801596 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 4 May 2026 20:19:58 -0700 Subject: [PATCH] ios: unify sidebars --- .../Sources/Sybil/SybilPhoneShellView.swift | 150 +---------- .../Sources/Sybil/SybilSidebarView.swift | 249 ++++++++++-------- 2 files changed, 145 insertions(+), 254 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index bf16d04..bef59df 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -357,64 +357,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 + open(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) { @@ -512,91 +463,6 @@ private struct SybilPhoneSidebarRoot: View { } } -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 { @Bindable var viewModel: SybilViewModel @Binding var composerFocusRequest: Int diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 1b4adf0..67a70ad 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -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,137 @@ 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 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(isSelected ? SybilTheme.accent : SybilTheme.textMuted) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(isSelected ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(isSelected ? 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 ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1) + ) + } +} + struct SybilSidebarActivityIndicator: View { var body: some View { ProgressView()