Files
hapi/ios/Hapi/Features/Chat/SessionConfigView.swift
T

318 lines
12 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// Compact, catalog-driven settings: navigation lists for permissions/models,
/// menu pickers for collaboration/effort. Selecting applies immediately; Done only dismisses.
struct SessionConfigView: View {
@State private var model: SessionConfigModel
let notice: String?
@Environment(\.dismiss) private var dismiss
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(interactor: ChatInteractor, notice: String? = nil) {
self.init(model: SessionConfigModel(interactor: interactor), notice: notice)
}
init(model: SessionConfigModel, notice: String? = nil) {
_model = State(initialValue: model)
self.notice = notice
}
var body: some View {
@Bindable var model = model
NavigationStack(path: $model.path) {
settingsForm
.navigationTitle("Session Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar { doneButton }
.navigationDestination(for: SessionConfigModel.Page.self) { page in
Group {
switch page {
case .permission: permissionList
case .model: modelList
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { doneButton }
}
}
.safeAreaInset(edge: .bottom, spacing: 0) { feedback }
.presentationDetents(
dynamicTypeSize.isAccessibilitySize || !model.path.isEmpty ? [.large] : [.medium, .large],
selection: Binding(
get: { dynamicTypeSize.isAccessibilitySize ? .large : model.detent },
set: { model.detent = $0 }
)
)
.onAppear { model.loadModels() }
}
private var settingsForm: some View {
Form {
if !model.config.active {
statusNotice("Session is offline — changes apply after it resumes or may be rejected.")
} else if model.config.controlledByUser {
statusNotice("Session is controlled from the terminal — config changes will be rejected.")
}
if model.hasSettings {
Section {
if !model.config.permissionModes.isEmpty {
NavigationLink(value: SessionConfigModel.Page.permission) {
SessionConfigSummaryRow(
title: "Permission mode", value: model.permission.label, tone: model.permission.tone
)
}
.accessibilityIdentifier("session-config-permission")
}
if model.showsCollaborationMode {
Picker("Collaboration Mode", selection: Binding(
get: { model.collaborationMode },
set: { model.selectCollaborationMode($0) }
)) {
ForEach(CodexCollaborationMode.allCases, id: \.self) { mode in
Text(LocalizedStringKey(mode.label)).tag(mode)
}
}
.pickerStyle(.menu)
.frame(minHeight: 44)
.disabled(!model.config.canChangeCollaborationMode)
.accessibilityIdentifier("session-config-collaboration")
}
if model.showsModel {
NavigationLink(value: SessionConfigModel.Page.model) {
SessionConfigSummaryRow(title: "Model", value: model.modelLabel)
}
.accessibilityIdentifier("session-config-model")
}
if model.showsEffort {
Picker("Effort", selection: Binding(
get: { model.currentEffort },
set: { model.selectEffort($0) }
)) {
ForEach(model.effortOptions, id: \.value) { option in
Text(verbatim: option.label)
.tag(option.value)
.disabled(!model.canSelectEffort(option.value))
}
}
.pickerStyle(.menu)
.frame(minHeight: 44)
.accessibilityIdentifier("session-config-effort")
}
}
.disabled(model.isApplying)
} else {
statusNotice("No settings available for this session.")
}
}
}
private var permissionList: some View {
List {
Section {
ForEach(model.config.permissionModes, id: \.mode) { option in
SessionConfigOptionRow(
label: option.label, selected: option.mode == model.permission, tone: option.tone
) {
model.selectPermission(option.mode)
}
.accessibilityIdentifier("session-config-permission-\(option.mode.rawValue)")
}
} footer: {
Text("Permission modes control how the agent requests approval. Behavior varies by agent.")
}
.disabled(model.isApplying)
}
.navigationTitle("Permission mode")
}
private var modelList: some View {
List {
if model.config.modelOptionsLoading {
Section {
ProgressView("Loading models…")
}
} else if model.modelLoadFailed {
Section {
Text("Failed to load models")
.foregroundStyle(.secondary)
Button("Retry") { model.loadModels() }
.accessibilityIdentifier("session-config-model-retry")
}
} else if model.config.modelOptions?.isEmpty != false {
statusNotice("Model list unavailable for this session.")
} else {
Section {
if let current = model.unlistedModel {
SessionConfigOptionRow(label: current, selected: true) {}
.disabled(true)
}
ForEach(model.config.modelOptions ?? [], id: \.value) { option in
SessionConfigOptionRow(label: option.label, selected: option.value == model.currentModel) {
model.selectModel(option.value)
}
.accessibilityIdentifier("session-config-model-\(option.value ?? "default")")
}
}
.disabled(model.isApplying)
}
}
.navigationTitle("Model")
}
@ToolbarContentBuilder
private var doneButton: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.accessibilityIdentifier("session-config-done")
}
}
/// ChatModel owns the event callback. Mirroring its notice here keeps
/// failures visible above the modal without stealing chat's event handler.
@ViewBuilder
private var feedback: some View {
if model.isApplying || notice != nil {
VStack(alignment: .leading, spacing: 8) {
if model.isApplying {
ProgressView("Applying changes…")
.accessibilityIdentifier("session-config-applying")
}
if let notice {
Label {
Text(verbatim: LocalizedNoticeMapper.map(notice))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "info.circle")
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("session-config-notice")
}
}
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.regularMaterial)
}
}
private func statusNotice(_ text: LocalizedStringKey) -> some View {
Section {
Label(text, systemImage: "info.circle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private struct SessionConfigSummaryRow: View {
let title: LocalizedStringKey
let value: String
var tone: PermissionModeTone = .neutral
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
Group {
if dynamicTypeSize.isAccessibilitySize {
stackedSummary
} else {
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
Text(title).fixedSize()
Spacer(minLength: 0)
currentValue.fixedSize()
}
stackedSummary
}
}
}
.frame(minHeight: 44)
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue(
tone == .danger
? Text(verbatim: value) + Text(verbatim: ", ") + Text("High risk")
: Text(verbatim: value)
)
}
private var stackedSummary: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
currentValue
}
.fixedSize(horizontal: false, vertical: true)
}
private var currentValue: some View {
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: value)
.foregroundStyle(tone == .neutral || tone == .info ? .secondary : tone.color)
if tone == .danger {
SessionConfigRiskLabel()
}
}
}
}
struct SessionConfigOptionRow: View {
let label: String
let selected: Bool
var tone: PermissionModeTone = .neutral
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(verbatim: label)
.foregroundStyle(tone.color)
if tone == .danger {
SessionConfigRiskLabel()
}
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
.accessibilityHidden(true)
}
}
.frame(minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
/// Explicit layout avoids Form's automatic Label style treating this subtitle
/// as a separate list-row label (which can clip or stretch its warning text).
private struct SessionConfigRiskLabel: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.accessibilityHidden(true)
Text("High risk")
}
.font(.caption)
.foregroundStyle(.red)
}
}
private extension PermissionModeTone {
var color: Color {
switch self {
case .danger: .red
case .warning: .orange
case .neutral, .info: .primary
}
}
}