Files
hapi/ios/Hapi/Features/Chat/ChatTranscriptView.swift
T
weishu 0dcc70e7c7 perf(native): optimize iOS transcript refresh and add frame profiling
Preserve hosted rows while propagating current SwiftUI environments and render captures. Add differential refresh regressions, opt-in real-clock iOS/Android profiling, and reproducible performance results.

Validation: typecheck; CLI/hub/web/shared/relay suites (Web rerun with NODE_OPTIONS=--no-experimental-webstorage for Node 26/jsdom compatibility); existing iOS Debug/Release and Android regression runs.
2026-09-10 16:22:36 +08:00

114 lines
4.3 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 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
)
}
}
}