mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +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.
297 lines
9.9 KiB
Swift
297 lines
9.9 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
// Value types + pure mapping logic of the chat interaction layer (A-M3ab),
|
|
// mirroring the Android `ChatViewModel`'s M3 surface. The stateful
|
|
// orchestration lives in `ChatInteractor`.
|
|
|
|
// MARK: - Composer
|
|
|
|
/// Composer bar state.
|
|
public struct ComposerState: Equatable, Sendable {
|
|
public let text: String
|
|
/// A send (or its inactive-session resume) is in flight — spinner on the
|
|
/// send button.
|
|
public let isSending: Bool
|
|
/// A turn is active: long-press send offers Steer, and Abort is shown.
|
|
public let canSteer: Bool
|
|
|
|
public init(text: String, isSending: Bool, canSteer: Bool) {
|
|
self.text = text
|
|
self.isSending = isSending
|
|
self.canSteer = canSteer
|
|
}
|
|
}
|
|
|
|
/// One row of the queued-messages bar (uninvoked sends).
|
|
public struct QueuedMessageRow: Equatable, Sendable, Identifiable {
|
|
public let id: String
|
|
public let localId: String?
|
|
public let text: String
|
|
public let attachmentNames: [String]
|
|
public let scheduledAt: Int?
|
|
/// Server echo has landed (`id != localId`) and no queued operation is in
|
|
/// flight — Cancel/Edit act only then (web `computeCanCancel`).
|
|
public let canAct: Bool
|
|
/// Steer offered: turn active, not future-scheduled, actionable.
|
|
public let canSteer: Bool
|
|
|
|
public init(
|
|
id: String,
|
|
localId: String?,
|
|
text: String,
|
|
attachmentNames: [String],
|
|
scheduledAt: Int?,
|
|
canAct: Bool,
|
|
canSteer: Bool
|
|
) {
|
|
self.id = id
|
|
self.localId = localId
|
|
self.text = text
|
|
self.attachmentNames = attachmentNames
|
|
self.scheduledAt = scheduledAt
|
|
self.canAct = canAct
|
|
self.canSteer = canSteer
|
|
}
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
/// Optimistic-permission UI state layered over the reduced blocks.
|
|
public enum PermissionRowOverride: Equatable, Sendable {
|
|
/// Decision POSTed; waiting for the agentState patch to settle it.
|
|
case resolving
|
|
|
|
/// The hub said the request is no longer pending (404/409) — benign.
|
|
case alreadyHandled
|
|
}
|
|
|
|
/// A permission decision the UI can request (bodies mirror
|
|
/// `PermissionFooter.tsx` / `AskUserQuestionFooter.tsx` /
|
|
/// `RequestUserInputFooter.tsx`).
|
|
public enum PermissionAction: Equatable, Sendable {
|
|
/// Plain allow — `{}` (claude family) or `{"decision":"approved"}`
|
|
/// (codex family).
|
|
case allow
|
|
|
|
/// Claude: `{"allowTools":[…]}`; everyone else:
|
|
/// `{"decision":"approved_for_session"}`.
|
|
case allowForSession
|
|
|
|
/// Claude edit tools: `{"mode":"acceptEdits"}`.
|
|
case allowAllEdits
|
|
|
|
/// Plain deny — `{}`.
|
|
case deny
|
|
|
|
/// Codex family: deny with `{"decision":"abort"}`.
|
|
case abort
|
|
|
|
/// AskUserQuestion: flat `{"<key>": ["label", …]}`.
|
|
case flatAnswers([String: [String]])
|
|
|
|
/// request_user_input: nested `{"<fieldId>": {"answers": […]}}`.
|
|
case nestedAnswers([String: [String]])
|
|
}
|
|
|
|
/// `PermissionFooter.isCodexSession` twin: codex family or cursor flavor, or
|
|
/// a codex-dialect tool name — selects both the button set and the plain
|
|
/// allow body.
|
|
public func isCodexPermissionUX(flavor: String?, toolName: String?) -> Bool {
|
|
if isCodexFamilyFlavor(flavor) || flavor == "cursor" {
|
|
return true
|
|
}
|
|
guard let toolName else { return false }
|
|
return toolName.hasPrefix("Codex")
|
|
|| toolName.hasPrefix("Gemini")
|
|
|| toolName.hasPrefix("OpenCode")
|
|
|| toolName.hasPrefix("Copilot")
|
|
|| toolName.hasPrefix("Cursor")
|
|
}
|
|
|
|
/// The web footer's tool-name gates.
|
|
public enum PermissionGates {
|
|
/// Tools whose approval may offer "Allow all edits" (claude).
|
|
public static let editTools: Set<String> = ["Edit", "MultiEdit", "Write", "NotebookEdit"]
|
|
|
|
/// Tools that never offer "Allow for this session".
|
|
public static let hideAllowForSession: Set<String> =
|
|
editTools.union(["exit_plan_mode", "ExitPlanMode", "CursorCreatePlan"])
|
|
}
|
|
|
|
/// Builds the approve body for one decision — the exact `PermissionFooter`
|
|
/// mapping table. `deny`/`abort` never reach this (they use the deny
|
|
/// endpoint).
|
|
public func permissionApproveBody(
|
|
action: PermissionAction,
|
|
flavor: String?,
|
|
toolName: String?,
|
|
arguments: JSONValue?
|
|
) -> PermissionApproveRequest {
|
|
switch action {
|
|
case .allow:
|
|
if isCodexPermissionUX(flavor: flavor, toolName: toolName) {
|
|
return PermissionApproveRequest(decision: .approved)
|
|
}
|
|
return PermissionApproveRequest()
|
|
|
|
case .allowForSession:
|
|
if flavor == "claude" {
|
|
let command = toolName == "Bash" ? inputString(arguments, keys: ["command", "cmd"]) : nil
|
|
let toolIdentifier: String
|
|
if toolName == "Bash", let command {
|
|
toolIdentifier = "Bash(\(command))"
|
|
} else {
|
|
toolIdentifier = toolName ?? ""
|
|
}
|
|
return PermissionApproveRequest(allowTools: [toolIdentifier])
|
|
}
|
|
return PermissionApproveRequest(decision: .approvedForSession)
|
|
|
|
case .allowAllEdits:
|
|
return PermissionApproveRequest(mode: .acceptEdits)
|
|
|
|
case .flatAnswers(let answers):
|
|
return PermissionApproveRequest(answers: .object(
|
|
answers.mapValues { values in .array(values.map(JSONValue.string)) }
|
|
))
|
|
|
|
case .nestedAnswers(let answers):
|
|
return PermissionApproveRequest(answers: .object(
|
|
answers.mapValues { values in
|
|
.object(["answers": .array(values.map(JSONValue.string))])
|
|
}
|
|
))
|
|
|
|
case .deny, .abort:
|
|
assertionFailure("deny actions do not build approve bodies")
|
|
return PermissionApproveRequest()
|
|
}
|
|
}
|
|
|
|
// MARK: - Session config
|
|
|
|
/// Load state of the per-session codex model catalog.
|
|
public enum CodexModelsState: Equatable, Sendable {
|
|
case idle
|
|
case loading
|
|
case loaded([CodexModelSummary])
|
|
case failed
|
|
}
|
|
|
|
/// Session config sheet model.
|
|
public struct SessionConfigState: Equatable, Sendable {
|
|
public let flavor: String?
|
|
public let active: Bool
|
|
/// Terminal-controlled sessions reject config posts with 409.
|
|
public let controlledByUser: Bool
|
|
/// Current wire mode; nil renders as the Default row.
|
|
public let permissionMode: PermissionMode?
|
|
/// Catalog modes for this flavor; empty → hide the section (pi).
|
|
public let permissionModes: [PermissionModeOption]
|
|
public let model: String?
|
|
/// nil → hide the model section (flavor without a known catalog).
|
|
public let modelOptions: [CatalogOption]?
|
|
/// True while the codex model catalog loads (sheet shows a spinner row).
|
|
public let modelOptionsLoading: Bool
|
|
/// Claude `effort` or codex `modelReasoningEffort`, whichever applies.
|
|
public let effort: String?
|
|
/// nil → hide the effort section.
|
|
public let effortOptions: [CatalogOption]?
|
|
|
|
public init(
|
|
flavor: String?,
|
|
active: Bool,
|
|
controlledByUser: Bool,
|
|
permissionMode: PermissionMode?,
|
|
permissionModes: [PermissionModeOption],
|
|
model: String?,
|
|
modelOptions: [CatalogOption]?,
|
|
modelOptionsLoading: Bool,
|
|
effort: String?,
|
|
effortOptions: [CatalogOption]?
|
|
) {
|
|
self.flavor = flavor
|
|
self.active = active
|
|
self.controlledByUser = controlledByUser
|
|
self.permissionMode = permissionMode
|
|
self.permissionModes = permissionModes
|
|
self.model = model
|
|
self.modelOptions = modelOptions
|
|
self.modelOptionsLoading = modelOptionsLoading
|
|
self.effort = effort
|
|
self.effortOptions = effortOptions
|
|
}
|
|
}
|
|
|
|
/// Catalog-driven derivation of the config sheet model (Android
|
|
/// `buildConfigUi`).
|
|
public func buildSessionConfigState(
|
|
detail: Session?,
|
|
summary: SessionSummary?,
|
|
codexModels: CodexModelsState
|
|
) -> SessionConfigState {
|
|
let flavor = detail?.metadata?.flavor ?? summary?.metadata?.flavor
|
|
let model = detail?.model
|
|
var modelOptions: [CatalogOption]?
|
|
var modelOptionsLoading = false
|
|
var effort: String?
|
|
var effortOptions: [CatalogOption]?
|
|
|
|
switch flavor {
|
|
case "claude":
|
|
modelOptions = ModelCatalog.claudeModelOptions(currentModel: model)
|
|
effort = detail?.effort
|
|
effortOptions = ModelCatalog.claudeEffortOptions(currentEffort: effort)
|
|
case "codex":
|
|
switch codexModels {
|
|
case .loaded(let models):
|
|
modelOptions = models.map { row in
|
|
CatalogOption(
|
|
value: row.id,
|
|
label: row.displayName + (row.isDefault ? " · default" : "")
|
|
)
|
|
}
|
|
let selected = models.first { $0.id == model } ?? models.first { $0.isDefault }
|
|
let efforts = selected?.supportedReasoningEfforts ?? []
|
|
if !efforts.isEmpty {
|
|
effort = detail?.modelReasoningEffort
|
|
effortOptions = [CatalogOption(value: nil, label: "Default")] + efforts.map { level in
|
|
CatalogOption(value: level, label: ModelCatalog.capitalizedFirst(level))
|
|
}
|
|
}
|
|
case .loading:
|
|
modelOptions = []
|
|
modelOptionsLoading = true
|
|
case .idle, .failed:
|
|
modelOptions = []
|
|
}
|
|
default:
|
|
modelOptions = nil // Generic fallback: hide the picker.
|
|
}
|
|
|
|
return SessionConfigState(
|
|
flavor: flavor,
|
|
active: detail?.active ?? summary?.active ?? false,
|
|
controlledByUser: detail?.agentState?.controlledByUser == true,
|
|
permissionMode: detail?.permissionMode,
|
|
permissionModes: permissionModeOptions(forFlavor: flavor),
|
|
model: model,
|
|
modelOptions: modelOptions,
|
|
modelOptionsLoading: modelOptionsLoading,
|
|
effort: effort,
|
|
effortOptions: effortOptions
|
|
)
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
/// One-shot side effects for the chat screen.
|
|
public enum ChatInteractionEvent: Equatable, Sendable {
|
|
/// Resume returned a different session id — renavigate to it.
|
|
case sessionSuperseded(sessionId: String)
|
|
|
|
/// Transient failure/notice for a toast.
|
|
case notice(String)
|
|
}
|