2026-05-06 21:53:51 -07:00
|
|
|
import MarkdownUI
|
|
|
|
|
import Observation
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct SybilQuickQuestionView: View {
|
|
|
|
|
@Bindable var viewModel: SybilViewModel
|
|
|
|
|
var focusRequest: Int
|
|
|
|
|
|
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
|
@FocusState private var promptFocused: Bool
|
|
|
|
|
|
|
|
|
|
private var hasAnswerContent: Bool {
|
|
|
|
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
header
|
|
|
|
|
|
|
|
|
|
answerArea
|
|
|
|
|
|
|
|
|
|
composer
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
.padding(.top, 18)
|
|
|
|
|
.padding(.bottom, 12)
|
|
|
|
|
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
|
|
|
.background(SybilTheme.backgroundGradient)
|
|
|
|
|
.preferredColorScheme(.dark)
|
|
|
|
|
.task(id: focusRequest) {
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(260))
|
|
|
|
|
guard !Task.isCancelled else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
promptFocused = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var header: some View {
|
|
|
|
|
HStack {
|
|
|
|
|
Image(systemName: "sparkles")
|
|
|
|
|
.font(.system(size: 21, weight: .semibold))
|
|
|
|
|
.foregroundStyle(SybilTheme.primary)
|
|
|
|
|
|
|
|
|
|
Text("Quick question")
|
|
|
|
|
.font(.title3.weight(.semibold))
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var answerArea: some View {
|
|
|
|
|
ScrollView {
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
if hasAnswerContent {
|
|
|
|
|
ForEach(viewModel.quickQuestionMessages) { message in
|
|
|
|
|
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let error = viewModel.quickQuestionError {
|
|
|
|
|
Text(error)
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(SybilTheme.danger)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
|
|
.padding(14)
|
|
|
|
|
}
|
|
|
|
|
.scrollDismissesKeyboard(.interactively)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.fill(Color.black.opacity(0.36))
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var composer: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack(alignment: .bottom, spacing: 10) {
|
|
|
|
|
TextField(
|
|
|
|
|
"Ask anything...",
|
|
|
|
|
text: Binding(
|
|
|
|
|
get: { viewModel.quickQuestionPrompt },
|
|
|
|
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
|
|
|
|
),
|
|
|
|
|
axis: .vertical
|
|
|
|
|
)
|
|
|
|
|
.focused($promptFocused)
|
|
|
|
|
.font(.body)
|
|
|
|
|
.textInputAutocapitalization(.sentences)
|
|
|
|
|
.autocorrectionDisabled(false)
|
|
|
|
|
.lineLimit(1 ... 6)
|
|
|
|
|
.submitLabel(.send)
|
|
|
|
|
.onSubmit(submitQuestion)
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.fill(SybilTheme.composerGradient)
|
|
|
|
|
.opacity(0.98)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
|
|
|
|
|
Button(action: submitQuestion) {
|
|
|
|
|
Image(systemName: "arrow.up")
|
|
|
|
|
.font(.body.weight(.semibold))
|
|
|
|
|
.frame(width: 40, height: 40)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
viewModel.canSendQuickQuestion
|
|
|
|
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
|
|
|
|
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.disabled(!viewModel.canSendQuickQuestion)
|
|
|
|
|
.accessibilityLabel("Ask quick question")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
controlsRow
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var convertButton: some View {
|
|
|
|
|
Button {
|
|
|
|
|
Task {
|
|
|
|
|
let didConvert = await viewModel.convertQuickQuestionToChat()
|
|
|
|
|
if didConvert {
|
|
|
|
|
dismiss()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Label("Chat", systemImage: "bubble.left")
|
|
|
|
|
.font(.caption.weight(.medium))
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
.minimumScaleFactor(0.8)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
.frame(maxWidth: .infinity, minHeight: 40)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.disabled(!viewModel.canConvertQuickQuestion)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var controlsRow: some View {
|
|
|
|
|
HStack(alignment: .center, spacing: 10) {
|
|
|
|
|
providerMenu
|
|
|
|
|
modelMenu
|
|
|
|
|
convertButton
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var providerMenu: some View {
|
|
|
|
|
Menu {
|
|
|
|
|
ForEach(viewModel.providerOptions, id: \.self) { provider in
|
|
|
|
|
Button {
|
|
|
|
|
viewModel.setQuickQuestionProvider(provider)
|
|
|
|
|
} label: {
|
|
|
|
|
if viewModel.quickQuestionProvider == provider {
|
|
|
|
|
Label(provider.displayName, systemImage: "checkmark")
|
|
|
|
|
} else {
|
|
|
|
|
Text(provider.displayName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
|
|
|
|
.accessibilityLabel("Quick question provider")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var modelMenu: some View {
|
|
|
|
|
Menu {
|
|
|
|
|
if viewModel.quickQuestionProviderModelOptions.isEmpty {
|
|
|
|
|
Text("No models")
|
|
|
|
|
} else {
|
|
|
|
|
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
|
|
|
|
|
Button {
|
|
|
|
|
viewModel.setQuickQuestionModel(model)
|
|
|
|
|
} label: {
|
|
|
|
|
if viewModel.quickQuestionModel == model {
|
|
|
|
|
Label(model, systemImage: "checkmark")
|
|
|
|
|
} else {
|
|
|
|
|
Text(model)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
|
|
|
|
.accessibilityLabel("Quick question model")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func submitQuestion() {
|
2026-05-09 20:49:27 -07:00
|
|
|
guard viewModel.canSendQuickQuestion else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
promptFocused = false
|
2026-05-06 21:53:51 -07:00
|
|
|
_ = viewModel.sendQuickQuestion()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct QuickQuestionPickerPill: View {
|
|
|
|
|
var title: String
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Text(title)
|
|
|
|
|
.font(.caption.weight(.medium))
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
.minimumScaleFactor(0.8)
|
|
|
|
|
|
|
|
|
|
Image(systemName: "chevron.down")
|
|
|
|
|
.font(.caption.weight(.semibold))
|
|
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
.frame(maxWidth: .infinity, minHeight: 40)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct QuickQuestionMessageView: View {
|
|
|
|
|
var message: Message
|
|
|
|
|
var isSending: Bool
|
|
|
|
|
|
|
|
|
|
private var isPendingAssistant: Bool {
|
|
|
|
|
message.id.hasPrefix("temp-assistant-quick-") &&
|
|
|
|
|
isSending &&
|
|
|
|
|
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
if let metadata = message.toolCallMetadata {
|
|
|
|
|
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
} else if isPendingAssistant {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.controlSize(.small)
|
|
|
|
|
.tint(SybilTheme.primary)
|
|
|
|
|
Text("Thinking...")
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
Markdown(message.content)
|
|
|
|
|
.font(.body)
|
|
|
|
|
.tint(SybilTheme.primary)
|
|
|
|
|
.foregroundStyle(SybilTheme.text.opacity(0.96))
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
|
|
|
|
|
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
|
|
|
|
|
return summary
|
|
|
|
|
}
|
|
|
|
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
return fallbackContent
|
|
|
|
|
}
|
|
|
|
|
return "Ran \(metadata.toolName ?? "tool")."
|
|
|
|
|
}
|
|
|
|
|
}
|