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) } } }