Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift
2026-05-03 21:06:20 -07:00

319 lines
13 KiB
Swift

import MarkdownUI
import SwiftUI
struct SybilSearchResultsView: View {
var search: SearchDetail?
var isLoading: Bool
var isRunning: Bool
var isStartingChat: Bool = false
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var onStartChat: (() -> Void)? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let query = search?.query, !query.isEmpty {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Results for")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
Text(query)
.font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true)
Text(resultCountLabel)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
}
if let onStartChat {
Button {
onStartChat()
} label: {
HStack(spacing: 8) {
if isStartingChat {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.text)
} else {
Image(systemName: "bubble.left.and.text.bubble.right")
.font(.system(size: 14, weight: .semibold))
}
Text(isStartingChat ? "Starting chat..." : "Chat with results")
.font(.sybil(.caption, weight: .semibold))
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.primary.opacity(0.14))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.disabled(!canStartChat)
.opacity(canStartChat ? 1 : 0.55)
}
}
}
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
answerCard
.frame(maxWidth: .infinity, alignment: .leading)
}
if (isLoading || isRunning) && (search?.results.isEmpty ?? true) {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text(isRunning ? "Searching Exa…" : "Loading search…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
}
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
Text("No results found.")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
ForEach(search?.results ?? []) { result in
SearchResultCard(result: result)
.accessibilityLabel("SearchResultCard")
.frame(maxWidth: .infinity, alignment: .leading)
}
if let error = search?.error, !error.isEmpty {
Text(error)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var resultCountLabel: String {
let count = search?.results.count ?? 0
let latency = search?.latencyMs
if let latency {
return "\(count) result\(count == 1 ? "" : "s")\(latency) ms"
}
return "\(count) result\(count == 1 ? "" : "s")"
}
private var canStartChat: Bool {
guard let search, !isLoading, !isRunning, !isStartingChat else {
return false
}
return search.answerText?.isEmpty == false || !search.results.isEmpty
}
@ViewBuilder
private var answerCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Answer")
.font(.sybil(.caption, weight: .semibold))
.textCase(.uppercase)
.foregroundStyle(SybilTheme.primary.opacity(0.92))
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Generating answer…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
} else if let answer = search?.answerText, !answer.isEmpty {
Markdown(answer)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text)
.markdownTheme(.sybilReadable)
}
if let answerError = search?.answerError, !answerError.isEmpty {
Text(answerError)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
}
if let citations = search?.answerCitations, !citations.isEmpty {
FlowLayout(spacing: 8) {
ForEach(Array(citations.prefix(8).enumerated()), id: \.offset) { index, citation in
if let link = citation.url ?? citation.id {
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
HStack(spacing: 6) {
Text("[\(index + 1)]")
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.primary)
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
.font(.sybil(.caption))
.lineLimit(1)
.foregroundStyle(SybilTheme.text)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(SybilTheme.primary.opacity(0.10))
.overlay(
Capsule()
.stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
}
}
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func host(for raw: String) -> String {
guard let url = URL(string: raw), let host = url.host else { return raw }
if host.hasPrefix("www.") {
return String(host.dropFirst(4))
}
return host
}
}
private struct SearchResultCard: View {
var result: SearchResultItem
private var resolvedURL: URL? {
URL(string: result.url)
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 7) {
Text(host)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.accent.opacity(0.9))
.lineLimit(1)
if let resolvedURL {
Link(destination: resolvedURL) {
Text(result.title.orEmpty.orFallback(result.url))
.font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
.multilineTextAlignment(.leading)
}
.buttonStyle(.plain)
} else {
Text(result.title.orEmpty.orFallback(result.url))
.font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
}
if let date = result.publishedDate, !date.isEmpty {
Text(date + (result.author.orEmpty.isEmpty ? "" : "\(result.author.orEmpty)"))
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
} else if let author = result.author, !author.isEmpty {
Text(author)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
}
Text(result.url)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.text.opacity(0.92))
.lineSpacing(2)
.lineLimit(3)
.textSelection(.enabled)
if let highlights = result.highlights, !highlights.isEmpty {
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
Text("\(highlight)")
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.96), SybilTheme.surface.opacity(0.72)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var host: String {
guard let resolvedURL, let host = resolvedURL.host else {
return result.url
}
if host.hasPrefix("www.") {
return String(host.dropFirst(4))
}
return host
}
}
private struct FlowLayout<Content: View>: View {
var spacing: CGFloat
@ViewBuilder var content: Content
init(spacing: CGFloat = 8, @ViewBuilder content: () -> Content) {
self.spacing = spacing
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: spacing) {
content
}
}
}
private extension Optional where Wrapped == String {
var orEmpty: String {
self ?? ""
}
}
private extension String {
func orFallback(_ fallback: String) -> String {
isEmpty ? fallback : self
}
}