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
136 lines
5.0 KiB
Swift
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)
|
|
}
|
|
}
|