TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
472 lines
20 KiB
Swift
472 lines
20 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SybilSidebarView: View {
|
|
@Bindable var viewModel: SybilViewModel
|
|
|
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
VStack(spacing: 10) {
|
|
sidebarActionButton(
|
|
title: "New chat",
|
|
systemImage: "plus",
|
|
isPrimary: true,
|
|
isActive: viewModel.draftKind == .chat
|
|
) {
|
|
viewModel.startNewChat()
|
|
}
|
|
|
|
sidebarActionButton(
|
|
title: "New search",
|
|
systemImage: "magnifyingglass",
|
|
isPrimary: false,
|
|
isActive: viewModel.draftKind == .search
|
|
) {
|
|
viewModel.startNewSearch()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 18)
|
|
.padding(.bottom, 10)
|
|
|
|
Divider()
|
|
.overlay(SybilTheme.border)
|
|
|
|
if let errorMessage = viewModel.errorMessage {
|
|
Text(errorMessage)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.danger)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
|
|
Divider()
|
|
.overlay(SybilTheme.border)
|
|
}
|
|
|
|
SybilSidebarItemList(
|
|
viewModel: viewModel,
|
|
isSelected: isSelected,
|
|
onSelect: { item in
|
|
viewModel.select(item.selection)
|
|
}
|
|
)
|
|
|
|
}
|
|
.background(SybilTheme.panelGradient)
|
|
.navigationTitle("")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
SybilWordmark(size: 18)
|
|
}
|
|
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
viewModel.openSettings()
|
|
} label: {
|
|
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sidebarActionButton(
|
|
title: String,
|
|
systemImage: String,
|
|
isPrimary: Bool,
|
|
isActive: Bool,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Label(title, systemImage: systemImage)
|
|
.font(.sybil(.subheadline, weight: .semibold))
|
|
.foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.text.opacity(0.92))
|
|
.padding(.horizontal, 13)
|
|
.padding(.vertical, 11)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 11)
|
|
.fill(isPrimary ? SybilTheme.primaryGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surface.opacity(0.62)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 11)
|
|
.stroke(isActive ? SybilTheme.primary.opacity(0.70) : SybilTheme.border.opacity(isPrimary ? 0.28 : 0.72), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
struct SybilSidebarItemList: View {
|
|
@Bindable var viewModel: SybilViewModel
|
|
var isSelected: (SidebarItem) -> Bool
|
|
var onSelect: (SidebarItem) -> Void
|
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
|
var onSelectAgent: (() -> Void)? = nil
|
|
var isAgentSelected: Bool? = nil
|
|
@State private var renameTarget: SidebarItem?
|
|
@State private var renameTitle = ""
|
|
@FocusState private var isSearchFocused: Bool
|
|
|
|
private var isRenameAlertPresented: Binding<Bool> {
|
|
Binding {
|
|
renameTarget != nil
|
|
} set: { isPresented in
|
|
if !isPresented {
|
|
renameTarget = nil
|
|
renameTitle = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Button {
|
|
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
AgentAvatar()
|
|
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
|
|
Spacer()
|
|
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
|
|
if viewModel.agent.unreadCount > 0 {
|
|
Text(String(viewModel.agent.unreadCount))
|
|
.font(.sybil(.caption, weight: .semibold))
|
|
.padding(.horizontal, 7).padding(.vertical, 3)
|
|
.background(SybilTheme.primary, in: Capsule())
|
|
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
|
|
}
|
|
}
|
|
.foregroundStyle(SybilTheme.text)
|
|
.padding(12)
|
|
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("agent-sidebar")
|
|
.padding(.horizontal, 10).padding(.top, 10)
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
TextField("Search chats and web searches", text: Binding(
|
|
get: { viewModel.sidebarSearchQuery },
|
|
set: { viewModel.setSidebarSearchQuery($0) }
|
|
))
|
|
.font(.sybil(.subheadline))
|
|
.foregroundStyle(SybilTheme.text)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.submitLabel(.search)
|
|
.focused($isSearchFocused)
|
|
.accessibilityLabel("Search conversation history and saved web searches")
|
|
.onSubmit {
|
|
isSearchFocused = false
|
|
Task { await viewModel.refreshSidebarSearch() }
|
|
}
|
|
|
|
if !viewModel.sidebarSearchQuery.isEmpty {
|
|
Button {
|
|
viewModel.setSidebarSearchQuery("")
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Clear history search")
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
|
|
.padding(10)
|
|
|
|
itemList
|
|
}
|
|
}
|
|
|
|
private var itemList: some View {
|
|
Group {
|
|
if viewModel.isSearchingSidebar ||
|
|
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ProgressView()
|
|
.tint(SybilTheme.primary)
|
|
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding(16)
|
|
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
|
|
VStack(spacing: 12) {
|
|
Text(error)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.danger)
|
|
.multilineTextAlignment(.center)
|
|
Button("Try again") {
|
|
Task { await viewModel.refreshSidebarSearch() }
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(16)
|
|
} else if viewModel.visibleSidebarItems.isEmpty {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
|
.font(.sybil(.footnote))
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
if viewModel.isSidebarSearchActive {
|
|
Text("Try a title, a query, or words from a conversation.")
|
|
.font(.sybil(.caption))
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(16)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
ForEach(viewModel.visibleSidebarItems) { item in
|
|
Button {
|
|
isSearchFocused = false
|
|
onSelect(item)
|
|
} label: {
|
|
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button {
|
|
Task {
|
|
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
|
}
|
|
} label: {
|
|
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
|
}
|
|
|
|
if case let .chat(chatID) = item.selection {
|
|
Button {
|
|
forkChat(item)
|
|
} label: {
|
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
|
}
|
|
.disabled(viewModel.isForkingChat || item.isRunning)
|
|
|
|
Button {
|
|
renameTarget = item
|
|
renameTitle = item.title
|
|
} label: {
|
|
Label("Rename", systemImage: "pencil")
|
|
}
|
|
|
|
Button {
|
|
UIPasteboard.general.string = chatID
|
|
} label: {
|
|
Label("Copy Session ID", systemImage: "doc.on.doc")
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
Task {
|
|
await viewModel.deleteItem(item.selection)
|
|
}
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
|
|
Text("Showing the 100 most recent matches. Refine your search to find more.")
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.padding(12)
|
|
}
|
|
}
|
|
.padding(10)
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.refreshable {
|
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
|
}
|
|
}
|
|
}
|
|
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
|
TextField("Title", text: $renameTitle)
|
|
Button("Cancel", role: .cancel) {
|
|
renameTarget = nil
|
|
renameTitle = ""
|
|
}
|
|
Button("Save") {
|
|
let target = renameTarget
|
|
let title = renameTitle
|
|
renameTarget = nil
|
|
renameTitle = ""
|
|
|
|
if let target, case let .chat(chatID) = target.selection {
|
|
Task {
|
|
await viewModel.renameChat(chatID: chatID, title: title)
|
|
}
|
|
}
|
|
}
|
|
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
|
|
private func forkChat(_ item: SidebarItem) {
|
|
guard case let .chat(chatID) = item.selection else {
|
|
return
|
|
}
|
|
|
|
let selectionAtStart = viewModel.selectedItem
|
|
let draftKindAtStart = viewModel.draftKind
|
|
Task {
|
|
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
|
viewModel.selectedItem == selectionAtStart,
|
|
viewModel.draftKind == draftKindAtStart else {
|
|
return
|
|
}
|
|
|
|
if let onOpenForkedChat {
|
|
onOpenForkedChat(forkedChat.id)
|
|
} else {
|
|
viewModel.select(.chat(forkedChat.id))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SybilSidebarRow: View {
|
|
var item: SidebarItem
|
|
var isSelected: Bool
|
|
var searchQuery = ""
|
|
|
|
private func highlightedSnippet(_ snippet: String) -> AttributedString {
|
|
var highlighted = AttributedString(snippet)
|
|
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
|
|
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
|
|
guard let range = Range(match.range, in: searchQuery) else { return nil }
|
|
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
|
}.filter { !$0.isEmpty }
|
|
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
|
|
guard let range = Range(word.range, in: snippet) else { continue }
|
|
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
|
guard terms.contains(where: { normalized.hasPrefix($0) }),
|
|
let start = AttributedString.Index(range.lowerBound, within: highlighted),
|
|
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
|
|
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
|
|
}
|
|
return highlighted
|
|
}
|
|
|
|
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)
|
|
|
|
if item.starred {
|
|
Image(systemName: "star.fill")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.yellow)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
if let snippet = item.searchSnippet, !snippet.isEmpty {
|
|
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
}
|
|
.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()
|
|
.progressViewStyle(.circular)
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.accent)
|
|
.scaleEffect(0.82)
|
|
.frame(width: 16, height: 16)
|
|
.accessibilityLabel("Generating")
|
|
}
|
|
}
|