chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user