Files
hapi/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.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

148 lines
4.7 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
import UIKit
// MARK: - List identity
extension VisibleChatBlock {
/// Stable list key: block ids are reducer-stable and tool-group ids are
/// pinned across recomputes via `previousGroups`, so scroll anchoring
/// and expansion state survive pipeline re-runs.
var stableId: String {
switch self {
case .block(let block): return block.id
case .toolGroup(let group): return group.id
}
}
/// Recycling bucket (the Android `contentKind` / web block `kind`).
var contentKind: String {
switch self {
case .block(let block): return block.kind
case .toolGroup: return "tool-group"
}
}
}
// MARK: - Media plumbing
/// Per-chat generated-image loading: `GET /generated-images/:imageId` bytes
/// through the authed APIClient (URLCache underneath serves immutable-ETag
/// repeats), plus a small in-memory decode cache so scroll-back does not
/// re-decode. Injected down the block tree via `\.chatMedia`.
@MainActor
final class GeneratedImageLoader {
private let api: APIClient
private let sessionId: String
private let cache = NSCache<NSString, UIImage>()
private var inFlight: [String: Task<UIImage?, Never>] = [:]
init(api: APIClient, sessionId: String) {
self.api = api
self.sessionId = sessionId
cache.countLimit = 40
}
func image(for imageId: String) async -> UIImage? {
if let cached = cache.object(forKey: imageId as NSString) {
return cached
}
if let running = inFlight[imageId] {
return await running.value
}
let api = api
let sessionId = sessionId
let task = Task<UIImage?, Never> {
guard let payload = try? await api.generatedImage(sessionId: sessionId, imageId: imageId) else {
return nil
}
return UIImage(data: payload.data)
}
inFlight[imageId] = task
let image = await task.value
inFlight[imageId] = nil
if let image {
cache.setObject(image, forKey: imageId as NSString)
}
return image
}
}
private struct ChatMediaKey: EnvironmentKey {
static let defaultValue: GeneratedImageLoader? = nil
}
extension EnvironmentValues {
/// The open chat's image loader; nil (previews/tests) degrades generated
/// images to a filename placeholder.
var chatMedia: GeneratedImageLoader? {
get { self[ChatMediaKey.self] }
set { self[ChatMediaKey.self] = newValue }
}
}
private struct ChatInteractionsKey: EnvironmentKey {
static let defaultValue: ChatInteractor? = nil
}
extension EnvironmentValues {
/// The open chat's interaction engine (A-M3ab), injected down the block
/// tree so deeply nested tool cards — groups, sidechains — reach the
/// permission actions and failed-send retry without prop threading. Nil
/// (previews/tests, read-only embeddings) keeps blocks in their M2
/// read-only rendering.
var chatInteractions: ChatInteractor? {
get { self[ChatInteractionsKey.self] }
set { self[ChatInteractionsKey.self] = newValue }
}
}
// MARK: - Dispatcher
/// One thread entry: routes a reduced `VisibleChatBlock` to its card — the
/// SwiftUI analogue of the web block-kind → component mapping and the
/// Android `ChatBlockCard`. Also used recursively for tool-call children
/// (sidechain transcripts).
struct ChatBlockCard: View {
let block: VisibleChatBlock
let basePath: String?
var body: some View {
switch block {
case .toolGroup(let group):
ToolGroupBlockView(block: group, basePath: basePath)
case .block(let chatBlock):
ChatSubBlockView(block: chatBlock, basePath: basePath)
}
}
}
/// Dispatcher for the plain `ChatBlock` union (shared by the thread and by
/// tool-card children).
struct ChatSubBlockView: View {
let block: ChatBlock
let basePath: String?
var body: some View {
switch block {
case .userText(let value):
UserTextBlockView(block: value)
case .agentText(let value):
AgentTextBlockView(block: value)
case .agentReasoning(let value):
AgentReasoningBlockView(block: value)
case .agentEvent(let value):
AgentEventBlockView(block: value)
case .cliOutput(let value):
CliOutputBlockView(block: value)
case .generatedImage(let value):
GeneratedImageBlockView(block: value)
case .codexReview(let value):
CodexReviewBlockView(block: value)
case .toolCall(let value):
ToolCallBlockView(block: value, basePath: basePath)
}
}
}