Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.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

153 lines
5.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import HapiProtocol
/// `order` query of `GET /api/sessions`. The default (absent) order is the
/// hub's ranked one (globalPinned → pinned → active → pending requests →
/// recency); `updatedAt` switches to pure recency.
public enum SessionsListOrder: String, Sendable {
case updatedAt
}
/// Session list, detail, and lifecycle endpoints
/// (`docs/api/client-contract/rest.md`).
extension APIClient {
/// `GET /api/sessions`.
public func listSessions(
limit: Int? = nil,
order: SessionsListOrder? = nil
) async throws -> [SessionSummary] {
var query: [URLQueryItem] = []
if let limit {
query.append(URLQueryItem(name: "limit", value: String(limit)))
}
if let order {
query.append(URLQueryItem(name: "order", value: order.rawValue))
}
let response: SessionsResponse = try await request(.get, "/api/sessions", query: query)
return response.sessions
}
/// `GET /api/sessions/:id`.
public func session(id: String) async throws -> Session {
let response: SessionResponse = try await request(
.get,
"/api/sessions/\(encodePathComponent(id))"
)
return response.session
}
/// `POST /api/sessions/:id/resume`. The returned session id **may differ**
/// from `id` (fresh spawn superseding the old row) — navigate to it and
/// migrate composer drafts.
public func resumeSession(id: String, permissionMode: PermissionMode? = nil) async throws -> String {
struct ResumeSessionRequest: Encodable {
let permissionMode: PermissionMode
}
let path = "/api/sessions/\(encodePathComponent(id))/resume"
let response: ResumeSessionResponse
if let permissionMode {
response = try await request(.post, path, body: ResumeSessionRequest(permissionMode: permissionMode))
} else {
response = try await request(.post, path)
}
return response.sessionId
}
/// `POST /api/sessions/:id/reopen`. As with resume, follow the returned
/// session id. Incomplete metadata answers `422 {error, missing[]}`.
public func reopenSession(id: String) async throws -> ReopenSessionResponse {
try await request(
.post,
"/api/sessions/\(encodePathComponent(id))/reopen",
body: EmptyRequestBody()
)
}
/// `POST /api/sessions/:id/abort` (active sessions only).
public func abortSession(id: String) async throws {
try await requestVoid(
.post,
"/api/sessions/\(encodePathComponent(id))/abort",
body: EmptyRequestBody()
)
}
/// `POST /api/sessions/:id/switch` — hands a terminal-controlled session
/// over to remote control.
public func switchSession(id: String) async throws {
try await requestVoid(
.post,
"/api/sessions/\(encodePathComponent(id))/switch",
body: EmptyRequestBody()
)
}
/// `POST /api/sessions/:id/archive` (409 for a plain inactive session).
public func archiveSession(id: String) async throws {
try await requestVoid(
.post,
"/api/sessions/\(encodePathComponent(id))/archive",
body: EmptyRequestBody()
)
}
/// `DELETE /api/sessions/:id` (409 while active — archive first).
public func deleteSession(id: String) async throws {
try await requestVoid(.delete, "/api/sessions/\(encodePathComponent(id))")
}
/// `PATCH /api/sessions/:id` — rename (1–255 chars).
public func renameSession(id: String, name: String) async throws {
struct RenameSessionRequest: Encodable {
let name: String
}
try await requestVoid(
.patch,
"/api/sessions/\(encodePathComponent(id))",
body: RenameSessionRequest(name: name)
)
}
/// `PATCH /api/sessions/:id/summary` (1–255 chars).
public func updateSessionSummary(id: String, text: String) async throws {
struct UpdateSummaryRequest: Encodable {
let text: String
}
try await requestVoid(
.patch,
"/api/sessions/\(encodePathComponent(id))/summary",
body: UpdateSummaryRequest(text: text)
)
}
/// `PUT /api/sessions/:id/pin`.
public func setSessionPinMode(id: String, mode: SessionPinMode) async throws {
struct PinRequest: Encodable {
let mode: SessionPinMode
}
try await requestVoid(
.put,
"/api/sessions/\(encodePathComponent(id))/pin",
body: PinRequest(mode: mode)
)
}
// MARK: - Session catalogs (RPC-wrapped — check `success`)
/// `GET /api/sessions/:id/slash-commands`.
public func slashCommands(sessionId: String) async throws -> SlashCommandsResponse {
try await request(.get, "/api/sessions/\(encodePathComponent(sessionId))/slash-commands")
}
/// `GET /api/sessions/:id/skills`.
public func skills(sessionId: String) async throws -> SkillsResponse {
try await request(.get, "/api/sessions/\(encodePathComponent(sessionId))/skills")
}
/// `GET /api/sessions/:id/codex-models` — the per-session codex model
/// catalog for the config sheet's model picker (A-M3ab).
public func sessionCodexModels(sessionId: String) async throws -> CodexModelsResponse {
try await request(.get, "/api/sessions/\(encodePathComponent(sessionId))/codex-models")
}
}