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: 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 } }