mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
386 lines
17 KiB
Swift
386 lines
17 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import HapiUI
|
|
import SwiftUI
|
|
|
|
/// Questions are conversations, not permission approvals. Pending questions
|
|
/// own one inline step; settled records open the existing read-only inspector.
|
|
struct QuestionToolCard: View {
|
|
let block: ToolCallBlock
|
|
@Environment(\.chatInteractions) private var interactions
|
|
@Environment(\.openChatTool) private var openTool
|
|
@Environment(\.hapiTheme) private var theme
|
|
@Environment(\.hapiTypography) private var typography
|
|
|
|
var body: some View {
|
|
let details = questionToolDetails(block.tool)
|
|
let override = block.tool.permission.flatMap { interactions?.permissionOverrides[$0.id] }
|
|
let state = QuestionCardState(tool: block.tool, details: details, override: override)
|
|
Group {
|
|
if (state == .answering || state == .submitting), let permission = block.tool.permission {
|
|
QuestionAnswerFormView(
|
|
tool: block.tool, requestId: permission.id,
|
|
enabled: interactions != nil, submitting: state == .submitting,
|
|
openDetails: { openTool?(block) },
|
|
submit: { interactions?.resolvePermission(requestId: permission.id, action: $0) }
|
|
)
|
|
} else {
|
|
summary(details: details, state: state)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(theme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
private func summary(details: QuestionToolDetails, state: QuestionCardState) -> some View {
|
|
Button { openTool?(block) } label: {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: state.icon)
|
|
.foregroundStyle(state == .failed ? theme.danger :
|
|
state == .answered ? theme.success : theme.textSecondary)
|
|
.accessibilityHidden(true)
|
|
Text(verbatim: state.title)
|
|
.font(typography.toolTitleFont)
|
|
Spacer(minLength: 8)
|
|
Image(systemName: "chevron.right")
|
|
.font(typography.captionFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
.accessibilityHidden(true)
|
|
}
|
|
ForEach(Array(details.questions.enumerated()), id: \.offset) { _, question in
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
let title = question.header ?? question.question
|
|
if !title.isEmpty {
|
|
Text(verbatim: title)
|
|
.font(typography.captionFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let answer = questionAnswerSummary(question) {
|
|
Text(verbatim: answer)
|
|
.font(typography.bodyFont)
|
|
.lineLimit(details.questions.count == 1 ? 2 : 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.foregroundStyle(theme.textPrimary)
|
|
.multilineTextAlignment(.leading)
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint("View full questions and answers")
|
|
.accessibilityIdentifier("tool-summary-\(block.id)")
|
|
}
|
|
|
|
}
|
|
|
|
struct QuestionAnswerFormView: View {
|
|
let requestId: String
|
|
let enabled: Bool
|
|
let submitting: Bool
|
|
let openDetails: (() -> Void)?
|
|
let submit: (PermissionAction) -> Void
|
|
private let form: QuestionAnswerForm
|
|
|
|
@ChatStoredState private var draft: QuestionAnswerDraft
|
|
@FocusState private var textFocused: Bool
|
|
@AccessibilityFocusState private var focusedQuestion: Int?
|
|
@Environment(\.hapiTheme) private var theme
|
|
@Environment(\.hapiTypography) private var typography
|
|
|
|
init(tool: ChatToolCall, requestId: String, enabled: Bool, submitting: Bool,
|
|
openDetails: (() -> Void)?, submit: @escaping (PermissionAction) -> Void) {
|
|
self.requestId = requestId
|
|
self.enabled = enabled
|
|
self.submitting = submitting
|
|
self.openDetails = openDetails
|
|
self.submit = submit
|
|
form = QuestionAnswerForm(tool: tool)
|
|
_draft = ChatStoredState(wrappedValue: QuestionAnswerDraft(), id: requestId, field: QuestionAnswerDraft.storageField)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
let index = draft.currentPage(in: form)
|
|
header(index: index)
|
|
if form.fields.indices.contains(index) {
|
|
question(index: index)
|
|
.id(index)
|
|
navigation(index: index)
|
|
} else {
|
|
Text("This question could not be displayed. Open details to inspect the request.")
|
|
.font(typography.bodyFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.onChange(of: draft.page) { _, _ in
|
|
textFocused = false
|
|
focusedQuestion = draft.currentPage(in: form)
|
|
}
|
|
.onChange(of: submitting) { _, value in
|
|
if value { textFocused = false }
|
|
}
|
|
}
|
|
|
|
private func header(index: Int) -> some View {
|
|
let title = form.fields.indices.contains(index) ? form.fields[index].header : nil
|
|
let layout = typography.usesStackedToolLayout
|
|
? AnyLayout(VStackLayout(alignment: .leading, spacing: 0))
|
|
: AnyLayout(HStackLayout(spacing: 8))
|
|
return layout {
|
|
Label {
|
|
Text(verbatim: title ?? String(localized: "Your answer"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} icon: {
|
|
Image(systemName: "questionmark.bubble")
|
|
.foregroundStyle(theme.accent)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.font(typography.toolTitleFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
if !typography.usesStackedToolLayout { Spacer(minLength: 0) }
|
|
HStack(spacing: 12) {
|
|
if form.fields.count > 1 {
|
|
Text(verbatim: "\(index + 1)/\(form.fields.count)")
|
|
.monospacedDigit()
|
|
.foregroundStyle(theme.textSecondary)
|
|
.accessibilityLabel(String(format: String(localized: "Question %lld of %lld"),
|
|
Int64(index + 1), Int64(form.fields.count)))
|
|
.accessibilityIdentifier("question-progress")
|
|
}
|
|
if let openDetails {
|
|
Button("Details", action: openDetails)
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
.accessibilityIdentifier("question-details-\(requestId)")
|
|
}
|
|
}
|
|
.font(typography.captionFont)
|
|
}
|
|
}
|
|
|
|
private func question(index: Int) -> some View {
|
|
let field = form.fields[index]
|
|
return VStack(alignment: .leading, spacing: 10) {
|
|
QuestionMarkdown(text: field.question.isEmpty ? String(localized: "Type your answer…") : field.question)
|
|
.foregroundStyle(theme.textPrimary)
|
|
.accessibilityAddTraits(.isHeader)
|
|
.accessibilityFocused($focusedQuestion, equals: index)
|
|
.accessibilityIdentifier("question-prompt-\(index)")
|
|
|
|
if field.multiple || !field.required {
|
|
Text(verbatim: [field.multiple ? String(localized: "Select all that apply") : nil,
|
|
!field.required ? String(localized: "Optional") : nil].compactMap { $0 }.joined(separator: " · "))
|
|
.font(typography.captionFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
}
|
|
|
|
VStack(spacing: 4) {
|
|
ForEach(field.options.indices, id: \.self) { optionIndex in
|
|
QuestionSelectionRow(
|
|
option: field.options[optionIndex],
|
|
selected: draft.selections[index]?.contains(optionIndex) == true,
|
|
multiple: field.multiple
|
|
) {
|
|
guard enabled, !submitting else { return }
|
|
textFocused = false
|
|
draft.select(optionIndex, at: index, in: form)
|
|
}
|
|
.disabled(!enabled || submitting)
|
|
.accessibilityIdentifier("question-\(index)-choice-\(optionIndex)")
|
|
}
|
|
}
|
|
textInput(index: index)
|
|
}
|
|
}
|
|
|
|
private func textTitle(index: Int) -> String {
|
|
let field = form.fields[index]
|
|
return field.options.isEmpty ? String(localized: "Your answer") :
|
|
field.allowsCustomAnswer ? String(localized: "Other answer") : String(localized: "Add a note…")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func textInput(index: Int) -> some View {
|
|
if draft.showsText(at: index, in: form) {
|
|
TextField(form.fields[index].placeholder ?? textTitle(index: index), text: Binding(
|
|
get: { draft.text(at: index, in: form) },
|
|
set: { draft.setText($0, at: index, in: form) }
|
|
), axis: .vertical)
|
|
.font(typography.bodyFont)
|
|
.lineLimit(2...6)
|
|
.padding(12)
|
|
.background(theme.background, in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(theme.divider, lineWidth: 1))
|
|
.focused($textFocused)
|
|
.disabled(!enabled || submitting)
|
|
.accessibilityLabel(textTitle(index: index))
|
|
.accessibilityIdentifier("question-\(index)-text")
|
|
}
|
|
}
|
|
|
|
private func navigation(index: Int) -> some View {
|
|
// Keep the note affordance and primary action on one quiet row when
|
|
// they fit. Narrow widths / large text use two rows, never truncation.
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(spacing: 12) {
|
|
secondaryActions(index: index).fixedSize(horizontal: true, vertical: false)
|
|
Spacer(minLength: 0)
|
|
primaryAction(index: index).fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
secondaryActions(index: index)
|
|
primaryAction(index: index, fullWidth: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func secondaryActions(index: Int) -> some View {
|
|
HStack(spacing: 8) {
|
|
if index > 0 {
|
|
Button { draft.previous(in: form) } label: {
|
|
Image(systemName: "chevron.left")
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityLabel("Previous question")
|
|
.accessibilityIdentifier("question-previous")
|
|
}
|
|
if !form.fields[index].options.isEmpty {
|
|
Button {
|
|
draft.toggleText(at: index, in: form)
|
|
textFocused = draft.showsText(at: index, in: form)
|
|
} label: {
|
|
Label {
|
|
Text(verbatim: textTitle(index: index))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} icon: {
|
|
Image(systemName: draft.showsText(at: index, in: form) ? "chevron.up" : "plus")
|
|
}
|
|
.font(typography.captionFont)
|
|
.frame(minHeight: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityIdentifier("question-\(index)-expand-text")
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(theme.accent)
|
|
.disabled(!enabled || submitting)
|
|
}
|
|
|
|
private func primaryAction(index: Int, fullWidth: Bool = false) -> some View {
|
|
let last = index == form.fields.count - 1
|
|
return Button {
|
|
guard enabled, !submitting else { return }
|
|
if last {
|
|
if let action = draft.submission(in: form) { submit(action) }
|
|
} else {
|
|
draft.next(in: form)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
if submitting { ProgressView().tint(.white).controlSize(.small) }
|
|
Text(submitting ? String(localized: "Submitting answer…") :
|
|
last ? String(localized: "Submit answer") : String(localized: "Next question"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.font(typography.toolTitleFont)
|
|
.frame(maxWidth: fullWidth ? .infinity : nil, minHeight: 32)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(theme.accent)
|
|
.disabled(!enabled || submitting || (last ? draft.submission(in: form) == nil : !draft.isAnswered(at: index, in: form)))
|
|
.accessibilityIdentifier(last ? "question-submit" : "question-next")
|
|
}
|
|
}
|
|
|
|
private struct QuestionSelectionRow: View {
|
|
let option: AskOption
|
|
let selected: Bool
|
|
let multiple: Bool
|
|
let action: () -> Void
|
|
@Environment(\.hapiTheme) private var theme
|
|
@Environment(\.hapiTypography) private var typography
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: multiple ? (selected ? "checkmark.square.fill" : "square") :
|
|
(selected ? "largecircle.fill.circle" : "circle"))
|
|
.font(typography.bodyFont)
|
|
.foregroundStyle(selected ? theme.accent : theme.textHint)
|
|
.padding(.top, 2)
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
QuestionOptionLabel(label: option.label)
|
|
if let description = option.description {
|
|
// Inline markdown keeps the caption metrics instead of
|
|
// promoting supporting text to full-size body paragraphs.
|
|
Text(.init(description))
|
|
.font(typography.captionFont)
|
|
.foregroundStyle(theme.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
// The button owns taps, not selectable Markdown/link children.
|
|
// Keep the outer label hit-testable so its full row still works.
|
|
.allowsHitTesting(false)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
|
.background(selected ? theme.accent.opacity(0.10) : .clear,
|
|
in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(selected ? theme.accent : theme.divider, lineWidth: 1))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.multilineTextAlignment(.leading)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityValue(selected ? String(localized: "Selected") : String(localized: "Not selected"))
|
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
|
}
|
|
}
|
|
|
|
struct QuestionOptionLabel: View {
|
|
let label: String
|
|
@Environment(\.hapiTheme) private var theme
|
|
@Environment(\.hapiTypography) private var typography
|
|
|
|
var body: some View {
|
|
let title = QuestionOptionTitle(label)
|
|
if title.recommended {
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(spacing: 8) {
|
|
QuestionMarkdown(text: title.text)
|
|
badge
|
|
}
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
QuestionMarkdown(text: title.text)
|
|
badge
|
|
}
|
|
}
|
|
.foregroundStyle(theme.textPrimary)
|
|
} else {
|
|
QuestionMarkdown(text: title.text).foregroundStyle(theme.textPrimary)
|
|
}
|
|
}
|
|
|
|
private var badge: some View {
|
|
Text("Recommended")
|
|
.font(typography.captionFont)
|
|
.foregroundStyle(theme.accent)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(theme.accent.opacity(0.08), in: Capsule())
|
|
}
|
|
}
|