Files
hapi/ios/Hapi/Features/Chat/ChatTranscriptView.swift
T
weishu 5ef4304a4a fix: synchronize read state across web and native clients
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.

Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
2026-11-01 09:52:51 +08:00

130 lines
4.8 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
import SwiftUI
/// Tool groups contribute only their displayed summary to transcript diffs.
enum TranscriptRow: Identifiable, Equatable {
case history(ChatHistoryPagingState.Phase, Bool)
case message(VisibleChatBlock)
case group(ToolGroupPresentation)
static let historyID = "chat-history-control"
var role: TranscriptRowRole {
switch self {
case .history: .history
case .group: .tool
case .message(.toolGroup): .tool
case .message(.block(.userText)): .user
case .message(.block(.toolCall)): .tool
default: .content
}
}
var id: String {
switch self {
case .history: Self.historyID
case .message(let block): block.stableId
case .group(let group): group.id
}
}
func spacing(after previous: TranscriptRow?) -> CGFloat {
return role.spacing(after: previous?.role)
}
}
struct ChatTranscriptView: View {
@Environment(\.scenePhase) private var scenePhase
let model: ChatModel
private var rows: [TranscriptRow] {
var rows: [TranscriptRow] = [.history(model.historyPaging.phase, model.hasMore)]
for block in model.blocks {
if case .toolGroup(let group) = block {
rows.append(.group(ToolGroupPresentation(group)))
} else {
rows.append(.message(block))
}
}
return rows
}
var body: some View {
let revision = model.readThroughRevision
let readState = model.renderedReadState
AnchoredTranscriptList(
items: rows,
historyVersion: model.historyVersion,
jumpToken: model.jumpToLatestToken,
historyControlID: TranscriptRow.historyID,
isInspectionPresented: model.isInspectingContent,
onViewport: { viewport in
model.readingViewportChanged(
followsTail: viewport.followsTail, needsOlder: viewport.needsOlder,
isAwayFromBottom: viewport.isAwayFromBottom
)
},
onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) },
onTailDisplayed: {
if scenePhase == .active { model.markDisplayed(revision: revision, serverState: readState) }
},
spacingBefore: { previous, row in row.spacing(after: previous) }
) { row in
// The list bridges the current environment into its hosting roots.
AnyView(rowView(row))
}
.overlay(alignment: .bottomTrailing) {
if model.showsJumpToLatest {
Button(action: model.jumpToLatest) {
HStack(spacing: 6) {
if model.isJumpingToLatest { ProgressView().controlSize(.small) }
Text("Back to latest")
Image(systemName: "arrow.down")
}
.font(.footnote.weight(.medium))
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(.regularMaterial, in: Capsule())
}
.disabled(model.isJumpingToLatest)
.accessibilityIdentifier("chat-latest")
.padding(12)
}
}
}
@ViewBuilder
private func rowView(_ row: TranscriptRow) -> some View {
switch row {
case .history(let phase, let hasMore):
Button(action: model.retryHistory) {
HStack(spacing: 8) {
if !hasMore {
Text("Beginning of conversation")
} else {
switch phase {
case .loading, .retrying, .awaitingLayout:
ProgressView().controlSize(.small)
Text("Loading older messages…")
case .failed: Text("Couldn't load history. Tap to retry.")
case .paused: Text("Continue loading history")
default: Text("Load older messages")
}
}
}
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!hasMore || model.isLoadingOlder || model.isSyncingTail)
.accessibilityIdentifier("chat-history")
case .message(let block):
ChatBlockCard(block: block, basePath: model.basePath)
case .group(let presentation):
ToolGroupBlockView(presentation: presentation)
}
}
}