Files
hapi/ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift
T
weishu a4355d1837 feat(ios): read-only chat assembly (A-M2f)
Wires the merged M2 pieces into a usable pair -> list -> live chat flow:

- pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the
  existing HapiProtocol/HapiClient product references; only project change).
- HubSession: MessageWindowControllers registry + per-chat ChatSession
  factory with in-memory per-session SSE resume cursors and scene-phase
  forwarding to the active chat.
- ChatSession (app): session-scope SSEClient while the chat is open;
  window opened/activated before subscribing; a single consume task awaits
  every event into the window actor, giving Android-channel-equivalent
  arrival-order ingestion; session-updated and friends route through the
  shared SyncEventRouter (detail patching), session-removed clears the
  window, a gap handshake triggers full resync + detail refetch + catch-up
  tail sync; stop hands the cursor back for seamless reopen.
- ChatPipeline (HapiClient): actor running the reduction path off-main -
  queued-row filter, normalization memoized by row instance identity,
  reduce + buildVisibleChatBlocks with previousGroups-stable group ids.
- ChatModel: window state + detail agentState + machine labels -> serial
  ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly
  ordered), header title cascade, loading/empty/error states, loadOlder /
  retry, last-seen stamping on every update.
- ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor +
  scrollPosition(id:)), auto-stick at bottom, new-messages pill, top
  sentinel paging with documented scroll re-anchoring, degraded banners.
- Block views: user bubble w/ attachment chips, agent markdown, collapsed
  reasoning, tool cards (knownTools.tsx-parity presentation, status chip,
  read-only permission row w/ pending banner, per-tool bodies: terminal,
  before/after edits, write content, unified-diff detection -> DiffTextView,
  checklists, read-only questions, pretty-JSON fallback, children rail),
  tool groups, centered event rows (presentation.ts port), cli output,
  generated images (authed bytes + in-memory cache + full-screen viewer),
  codex review verdict card.
- Navigation/links: session list now pushes ChatView; app-level
  \.hapiOpenURL handler (https/http -> SFSafariViewController, custom
  schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection.
- Tests: ChatPipelineTests drive the runner with fixture-derived windows
  (stable ids, memo-stable recomputes, queued filter, group-id stability
  across an older-page arrival).
- README: M2f app-layer description.
2026-08-17 20:55:36 +08:00

132 lines
4.1 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 }
}
}
// 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)
}
}
}