mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
318 lines
12 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|