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 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) } ) { 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, maxHeight: 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 ) } } }