mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Use unified 16pt Dynamic Type body text, clearer Markdown spacing, and a shared reading column. Preserve transcript anchors across typography changes and bound tool command previews to two lines. Add layout and typography regression tests.
125 lines
4.7 KiB
Swift
125 lines
4.7 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import HapiUI
|
|
import SwiftUI
|
|
|
|
/// Presentation-only flattening; protocol groups and their semantics stay intact.
|
|
private enum TranscriptRow: Identifiable, Equatable {
|
|
case history(ChatHistoryPagingState.Phase, Bool)
|
|
case message(VisibleChatBlock)
|
|
case group(ToolGroupBlock, Bool)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let expanded = model.expandedToolGroups[group.id] ?? group.defaultOpen
|
|
rows.append(.group(group, expanded))
|
|
if expanded {
|
|
rows.append(contentsOf: group.tools.map { .message(.block(.toolCall($0))) })
|
|
}
|
|
} else {
|
|
rows.append(.message(block))
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
var body: some View {
|
|
AnchoredTranscriptList(
|
|
items: rows,
|
|
historyVersion: model.historyVersion,
|
|
jumpToken: model.jumpToLatestToken,
|
|
historyControlID: TranscriptRow.historyID,
|
|
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.role.spacing(after: previous?.role) }
|
|
) { 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 group, let expanded):
|
|
ToolGroupBlockView(
|
|
block: group,
|
|
basePath: model.basePath,
|
|
expansion: Binding(get: { expanded }, set: { model.expandedToolGroups[group.id] = $0 }),
|
|
showsTools: false
|
|
)
|
|
}
|
|
}
|
|
}
|