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() { guard viewModel.canSendQuickQuestion else { return } promptFocused = false _ = 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")." } }