Files
hapi/ios/Hapi/Features/Chat/ChatTranscriptView.swift
T

121 lines
4.4 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 {
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 {
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)
},
onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) },
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.followsTail || model.hasTrimmedTail || model.isJumpingToLatest {
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)
}
}
}