Files
1jehuang--jcode/ios/Sources/JCodeMobile/Views/TranscriptView.swift
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

136 lines
5.0 KiB
Swift

import JCodeKit
import SwiftUI
/// Scrolling transcript with pinned-to-bottom auto-follow.
///
/// Auto-scroll only engages while the user is at (or near) the bottom, so
/// scrolling up to read history is never hijacked by streaming output. A
/// jump-to-latest button appears whenever the view is unpinned. Short threads
/// are anchored to the bottom (chat convention); once the content exceeds the
/// viewport it scrolls normally. An empty session shows a centered placeholder.
struct TranscriptView: View {
let entries: [TranscriptEntry]
let isReasoning: Bool
/// True while the viewport is at (or near) the bottom of the content.
@State private var isPinnedToBottom = true
/// Distance from the bottom below which the view counts as pinned.
private static let pinThreshold: CGFloat = 56
var body: some View {
if entries.isEmpty && !isReasoning {
EmptyTranscript()
} else {
GeometryReader { viewport in
scroller(viewportHeight: viewport.size.height)
}
}
}
private func scroller(viewportHeight: CGFloat) -> some View {
ScrollViewReader { proxy in
ScrollView {
// A flexible top spacer pushes short content to the bottom of
// the viewport; it collapses to zero once content overflows.
LazyVStack(alignment: .leading, spacing: 16) {
Spacer(minLength: 0)
ForEach(entries) { entry in
EntryView(entry: entry)
.id(entry.id)
}
if isReasoning {
thinkingRow
}
Color.clear.frame(height: 1).id("bottom")
}
.frame(minHeight: max(0, viewportHeight - 16), alignment: .bottom)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
GeometryReader { content in
Color.clear.preference(
key: BottomDistanceKey.self,
value: content.frame(in: .named("transcript")).maxY
- viewportHeight
)
}
)
}
.coordinateSpace(name: "transcript")
.scrollDismissesKeyboard(.interactively)
.onPreferenceChange(BottomDistanceKey.self) { distance in
MainActor.assumeIsolated {
let pinned = distance < Self.pinThreshold
if pinned != isPinnedToBottom {
isPinnedToBottom = pinned
}
}
}
.onChange(of: entries.last?.text) {
guard isPinnedToBottom else { return }
proxy.scrollTo("bottom", anchor: .bottom)
}
.onChange(of: entries.count) {
// Follow new entries when pinned; always follow the user's
// own sends so their message never lands off-screen.
if isPinnedToBottom || entries.last?.role == .user {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.overlay(alignment: .bottomTrailing) {
if !isPinnedToBottom {
ScrollToBottomButton {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.padding(.trailing, 16)
.padding(.bottom, 8)
}
}
}
}
private var thinkingRow: some View {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(Theme.textTertiary)
Text("thinking")
.font(Theme.mono(12))
.foregroundStyle(Theme.textTertiary)
}
.padding(.leading, 4)
}
}
/// How far the content's bottom edge sits below the viewport's bottom edge.
private struct BottomDistanceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
/// Floating jump-to-latest affordance shown while scrolled up.
private struct ScrollToBottomButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "arrow.down")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
.frame(width: 44, height: 44)
.background(Theme.surfaceElevated)
.clipShape(Circle())
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
}
.accessibilityLabel("Scroll to bottom")
.accessibilityHint("Jumps to the latest message")
.transition(.opacity)
}
}