Files
hapi/ios/Hapi/Features/Chat/SessionConfigView.swift
T
weishu eed3dddb94 feat(ios): composer, permission actions, session config (A-M3ab)
Interaction layer turning the read-only chat into a working remote control,
mirroring the merged Android B-M3ab feature-for-feature (web authority where
the ports disagreed):

- ChatInteractor (HapiClient, fully swift-test covered): optimistic composer
  sends (appendOptimistic -> POST -> status settle), queue-by-default with a
  long-press Send&Steer intent while a turn is active, tap-to-retry on failed
  rows (steer retries degrade to queue), per-session drafts
  (UserDefaultsChatDrafts, hub-scoped keys, debounced + flushed on close).
- session_inactive (409) recovery: one POST /resume (current permissionMode)
  then retry; a superseding session id seeds the new window
  (MessageWindowControllers.seed), migrates the draft, retargets the
  optimistic row and emits sessionSuperseded -- ChatView/HomeView replace the
  navigation entry in place.
- Queued bar: uninvoked sends in web sort order with Cancel (optimistic
  DELETE; invoked-race ingests the authoritative row as sent), Edit
  (cancel + composer prefill, newer-draft guard) and Steer (invoked answers
  reconcile a missed consume); single-flight per-row op guard.
  reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
  claude {} / allowTools (Bash(cmd)) / mode:acceptEdits, codex-family
  decision approved / approved_for_session / abort via deny -- plus
  AskUserQuestion flat answers (option cards, Other free text, no-questions
  fallback, cursor stable ids) and request_user_input nested answers
  (user_note suffix, required validation); optimistic Resolving /
  AlreadyHandled (404/409) overrides settled by the agentState patch.
  ChatPipeline now re-attaches the window row's client status so failed
  user rows actually render the retry affordance (web normalize.ts parity;
  the Android reference misses this overlay).
- Session config sheet (toolbar gear): catalog-driven permission-mode picker
  with tones, claude static model/effort catalogs (ModelCatalog port), codex
  models via new GET /sessions/:id/codex-models endpoint + wire types with
  per-model reasoning efforts; optimistic detail updates
  (SessionListStore.updateDetailLocal, new) rolled forward to server truth
  on error.
- Lifecycle: VisibilityReporter posts POST /api/visibility per tracked
  handshake subscriptionId on scene-phase flips (404 prunes); ChatSession
  exposes its subscriptionId and feeds the reporter; the global SSE pipe was
  already HubSession-lifetime on iOS.
- Tests: ChatInteractorTests transcribes the Android interaction suite
  against the real APIClient/AuthManager/SessionListStore/window registry
  with only HTTP scripted -- canonical JSON bodies asserted byte-for-byte
  (send/approve/deny/config), optimistic send happy/fail/retry, 409 resume
  both id paths, cancel invoked-race, steer reconcile, edit prefill,
  override lifecycle, config optimistic + rollback, drafts, abort.
2026-08-18 10:33:04 +08:00

146 lines
5.1 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// Session config sheet (A-M3b): permission mode / model / effort sections,
/// catalog-driven per flavor — the SwiftUI twin of the Android
/// `SessionConfigSheet`. Pickers apply optimistically through the interactor
/// (`setPermissionMode` & co., server truth reloaded on error); a flavor
/// without a known model catalog simply hides that section.
struct SessionConfigView: View {
let interactor: ChatInteractor
@Environment(\.dismiss) private var dismiss
var body: some View {
let config = interactor.config
NavigationStack {
List {
if !config.active {
notice("Session is offline — changes apply after it resumes or may be rejected.")
} else if config.controlledByUser {
notice("Session is controlled from the terminal — config changes will be rejected.")
}
if !config.permissionModes.isEmpty {
Section("Permission mode") {
ForEach(config.permissionModes, id: \.mode) { option in
OptionRow(
label: option.label,
selected: option.mode == (config.permissionMode ?? .default),
tone: option.tone
) {
interactor.setPermissionMode(option.mode)
}
}
}
}
if config.modelOptions != nil || config.modelOptionsLoading {
Section("Model") {
modelSection(config)
}
}
if let effortOptions = config.effortOptions {
Section("Effort") {
let current = normalizedCurrentEffort(config)
ForEach(effortOptions, id: \.self) { option in
OptionRow(label: option.label, selected: option.value == current) {
interactor.setEffort(option.value)
}
}
}
}
}
.navigationTitle("Session Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
interactor.loadModelOptions()
}
}
@ViewBuilder
private func modelSection(_ config: SessionConfigState) -> some View {
if config.modelOptionsLoading {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Loading models…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else if config.modelOptions?.isEmpty != false {
Text("Model list unavailable for this session.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
let current = normalizedCurrentModel(config)
ForEach(config.modelOptions ?? [], id: \.self) { option in
OptionRow(label: option.label, selected: option.value == current) {
interactor.setModel(option.value)
}
}
}
}
/// Claude models normalize `auto`/`default` to the nil Default row.
private func normalizedCurrentModel(_ config: SessionConfigState) -> String? {
config.flavor == "claude" ? ModelCatalog.normalizeClaudeModel(config.model) : config.model
}
private func normalizedCurrentEffort(_ config: SessionConfigState) -> String? {
config.flavor == "claude" ? ModelCatalog.normalizeClaudeEffort(config.effort) : config.effort
}
private func notice(_ text: String) -> some View {
Section {
Label(text, systemImage: "info.circle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
/// One catalog row: label + trailing checkmark, tinted by the mode's tone.
private struct OptionRow: View {
let label: String
let selected: Bool
var tone: PermissionModeTone = .neutral
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
Text(label)
.foregroundStyle(toneColor)
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var toneColor: Color {
switch tone {
case .danger: .red
case .warning: .orange
case .neutral, .info: .primary
}
}
}