mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
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.
130 lines
4.8 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|