mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-18 20:59:49 +00:00
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.
146 lines
5.1 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|