Merge branch 'dev' into canary
This commit is contained in:
@@ -8,6 +8,9 @@ import NucleicProtocol
|
|||||||
struct ApprovalCardView: View {
|
struct ApprovalCardView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let approval: ApprovalRequest
|
let approval: ApprovalRequest
|
||||||
|
/// The transcript's on-screen height, so the card can cap itself and scroll its detail rather
|
||||||
|
/// than growing past the screen and pushing Allow/Deny out of reach. 0 until measured.
|
||||||
|
var availableHeight: CGFloat = 0
|
||||||
|
|
||||||
@State private var showAlwaysMenu = false
|
@State private var showAlwaysMenu = false
|
||||||
|
|
||||||
@@ -27,22 +30,14 @@ struct ApprovalCardView: View {
|
|||||||
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
||||||
.foregroundStyle(approval.risk.color)
|
.foregroundStyle(approval.risk.color)
|
||||||
}
|
}
|
||||||
// A git commit reads as a structured commit card (subject + Markdown body) so you can
|
|
||||||
// see exactly what you're granting; the literal command stays under "Show command".
|
// The detail (commit card, host breakdown + command, or plain text) scrolls inside a
|
||||||
// Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every
|
// bounded region so a giant command can't grow the card off-screen; the buttons below
|
||||||
// other tool shows its untruncated detail in a single scrollable box.
|
// stay pinned and always tappable. Rendered only when there's detail to show.
|
||||||
if let command = approval.input["command"]?.stringValue,
|
if hasDetail {
|
||||||
let commit = GitCommitSummary.parse(command) {
|
BoundedCardScroll(maxHeight: middleMaxHeight) {
|
||||||
GitCommitCard(commit: commit, rawCommand: command)
|
VStack(alignment: .leading, spacing: 10) { detailContent }
|
||||||
} else if let command = approval.input["command"]?.stringValue,
|
}
|
||||||
let parsed = HostCommandSummary.summary(for: command),
|
|
||||||
approval.toolName == HostCommandSummary.hostExecToolName
|
|
||||||
|| parsed.invocations.count > 1 || parsed.isDestructive {
|
|
||||||
HostCommandBreakdown(summary: parsed)
|
|
||||||
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
||||||
ApprovalDetailBox(text: command)
|
|
||||||
} else if !approval.input.approvalDetail.isEmpty {
|
|
||||||
ApprovalDetailBox(text: approval.input.approvalDetail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -83,6 +78,73 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassSurface(cornerRadius: 20)
|
.glassSurface(cornerRadius: 20)
|
||||||
|
// A hard ceiling on the whole card: even if the chrome estimate is off, the card can never
|
||||||
|
// exceed the screen. `nil` (before the height is measured) leaves it unconstrained.
|
||||||
|
.frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail
|
||||||
|
|
||||||
|
/// The kinds of detail an approval carries, resolved once so the body and `hasDetail` agree.
|
||||||
|
/// A git commit reads as a structured commit card (subject + Markdown body); a host_exec gate
|
||||||
|
/// lays out its parsed breakdown + exact command; every other tool shows its untruncated detail.
|
||||||
|
private enum Detail {
|
||||||
|
case commit(GitCommitSummary.Commit, command: String)
|
||||||
|
case host(HostCommandSummary.Summary, command: String)
|
||||||
|
case text(String)
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: Detail {
|
||||||
|
if let command = approval.input["command"]?.stringValue,
|
||||||
|
let commit = GitCommitSummary.parse(command) {
|
||||||
|
return .commit(commit, command: command)
|
||||||
|
}
|
||||||
|
if let command = approval.input["command"]?.stringValue,
|
||||||
|
let parsed = HostCommandSummary.summary(for: command),
|
||||||
|
approval.toolName == HostCommandSummary.hostExecToolName
|
||||||
|
|| parsed.invocations.count > 1 || parsed.isDestructive {
|
||||||
|
return .host(parsed, command: command)
|
||||||
|
}
|
||||||
|
let text = approval.input.approvalDetail
|
||||||
|
return text.isEmpty ? .none : .text(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasDetail: Bool {
|
||||||
|
if case .none = detail { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var detailContent: some View {
|
||||||
|
switch detail {
|
||||||
|
case .commit(let commit, let command):
|
||||||
|
GitCommitCard(commit: commit, rawCommand: command)
|
||||||
|
case .host(let parsed, let command):
|
||||||
|
HostCommandBreakdown(summary: parsed)
|
||||||
|
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||||
|
ApprovalDetailBox(text: Self.truncatedForDisplay(command))
|
||||||
|
case .text(let text):
|
||||||
|
ApprovalDetailBox(text: Self.truncatedForDisplay(text))
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The height the scrolling detail may take before it starts scrolling — the screen minus a
|
||||||
|
/// generous reserve for the header, risk tag, buttons, and "Allow always…" menu, so those
|
||||||
|
/// always have room. Falls back to a roomy default before the height is measured.
|
||||||
|
private var middleMaxHeight: CGFloat {
|
||||||
|
let base = availableHeight > 0 ? availableHeight - 12 : 640
|
||||||
|
return max(140, base - 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A very long command/detail is capped for display so one giant blob can't dominate the card;
|
||||||
|
/// the middle still scrolls for the rest. The full command is still what gets approved — the
|
||||||
|
/// breakdown above spells out its structure regardless.
|
||||||
|
private static func truncatedForDisplay(_ text: String, limit: Int = 4000) -> String {
|
||||||
|
guard text.count > limit else { return text }
|
||||||
|
let omitted = text.count - limit
|
||||||
|
return String(text.prefix(limit)) + "\n… [\(omitted) more characters truncated]"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
||||||
@@ -94,33 +156,47 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The full content of an approval, wrapped and selectable. The box sizes to its
|
/// The full content of an approval, wrapped and selectable. It no longer scrolls on its own — the
|
||||||
/// content and only begins scrolling once the content exceeds `maxHeight`, so a short
|
/// enclosing ``BoundedCardScroll`` owns the scrolling for the whole card — so it simply sizes to
|
||||||
/// command sits in a snug box while a large paste stays bounded. The text wraps; it
|
/// its (already length-capped) text. The text wraps; it never scrolls horizontally.
|
||||||
/// never scrolls horizontally.
|
|
||||||
private struct ApprovalDetailBox: View {
|
private struct ApprovalDetailBox: View {
|
||||||
let text: String
|
let text: String
|
||||||
private let maxHeight: CGFloat = 200
|
|
||||||
@State private var contentHeight: CGFloat = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(GeometryReader { geo in
|
|
||||||
Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
|
|
||||||
.onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 }
|
|
||||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ApprovalDetailHeightKey: PreferenceKey {
|
/// Wraps the variable-height middle of an input card so it scrolls only once it would exceed
|
||||||
|
/// `maxHeight`. Below that it sizes to its content (a snug card); above it the content scrolls
|
||||||
|
/// while the card's header and action buttons stay pinned on-screen — so a giant command or a long
|
||||||
|
/// list of options can never push Allow/Deny (or Submit) off the bottom of the phone. Shared by the
|
||||||
|
/// approval and question cards.
|
||||||
|
struct BoundedCardScroll<Content: View>: View {
|
||||||
|
let maxHeight: CGFloat
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
@State private var contentHeight: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
content
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(GeometryReader { geo in
|
||||||
|
Color.clear.preference(key: CardContentHeightKey.self, value: geo.size.height)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
|
.onPreferenceChange(CardContentHeightKey.self) { contentHeight = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CardContentHeightKey: PreferenceKey {
|
||||||
static var defaultValue: CGFloat { 0 }
|
static var defaultValue: CGFloat { 0 }
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
value = max(value, nextValue())
|
value = max(value, nextValue())
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ struct AskUserQuestionCardView: View {
|
|||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let approval: ApprovalRequest
|
let approval: ApprovalRequest
|
||||||
let questions: [AskUserQuestion.Question]
|
let questions: [AskUserQuestion.Question]
|
||||||
|
/// The transcript's on-screen height, so a long question (many options / long descriptions) can
|
||||||
|
/// scroll inside a bounded region instead of growing the card past the screen and pushing the
|
||||||
|
/// Deny / Back / Next / Submit buttons out of reach. 0 until measured.
|
||||||
|
var availableHeight: CGFloat = 0
|
||||||
|
|
||||||
/// Selected option labels per question text.
|
/// Selected option labels per question text.
|
||||||
@State private var selected: [String: Set<String>] = [:]
|
@State private var selected: [String: Set<String>] = [:]
|
||||||
@@ -35,6 +39,7 @@ struct AskUserQuestionCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let question = currentQuestion {
|
if let question = currentQuestion {
|
||||||
|
BoundedCardScroll(maxHeight: middleMaxHeight) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if questions.count > 1 {
|
if questions.count > 1 {
|
||||||
Text("Question \(step + 1) of \(questions.count)")
|
Text("Question \(step + 1) of \(questions.count)")
|
||||||
@@ -51,6 +56,7 @@ struct AskUserQuestionCardView: View {
|
|||||||
otherRow(question)
|
otherRow(question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -89,6 +95,17 @@ struct AskUserQuestionCardView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassSurface(cornerRadius: 20)
|
.glassSurface(cornerRadius: 20)
|
||||||
|
// A hard ceiling on the whole card so it can never exceed the screen; `nil` (before the
|
||||||
|
// height is measured) leaves it unconstrained.
|
||||||
|
.frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The height the scrolling question region may take before it starts scrolling — the screen
|
||||||
|
/// minus a reserve for the header and the button row, so those always have room. Falls back to
|
||||||
|
/// a roomy default before the height is measured.
|
||||||
|
private var middleMaxHeight: CGFloat {
|
||||||
|
let base = availableHeight > 0 ? availableHeight - 12 : 640
|
||||||
|
return max(160, base - 170)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wizard state
|
// MARK: - Wizard state
|
||||||
|
|||||||
@@ -18,13 +18,31 @@ struct SessionDetailView: View {
|
|||||||
// Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to
|
// Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to
|
||||||
// read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
|
// read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
|
||||||
@State private var scrollToBottomRequest = 0
|
@State private var scrollToBottomRequest = 0
|
||||||
|
// Owned here (not inside `TranscriptList`) so the jump-to-bottom chevron can ride in the chat
|
||||||
|
// bar just above the composer. `TranscriptList` drives it from the scroll geometry.
|
||||||
|
@State private var isScrolledToBottom = true
|
||||||
|
// The transcript's on-screen height, measured so the floating approval / question card can cap
|
||||||
|
// itself to the screen: a long host command or a long list of options must never grow the card
|
||||||
|
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
|
||||||
|
@State private var availableHeight: CGFloat = 0
|
||||||
|
|
||||||
private var summary: WireSessionSummary? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest)
|
TranscriptList(events: store.openEvents,
|
||||||
|
scrollToBottomRequest: scrollToBottomRequest,
|
||||||
|
isScrolledToBottom: $isScrolledToBottom)
|
||||||
|
// Measure the transcript's full height (the floating bar overlays it via safeAreaInset,
|
||||||
|
// so this frame is the whole screen area the card has to live within) and hand it to the
|
||||||
|
// action card so it can bound itself.
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 }
|
||||||
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
|
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
|
||||||
// in a boxed strip below it, so the transcript runs the full height of the screen.
|
// in a boxed strip below it, so the transcript runs the full height of the screen.
|
||||||
.safeAreaInset(edge: .bottom) { actionArea }
|
.safeAreaInset(edge: .bottom) { actionArea }
|
||||||
@@ -236,6 +254,15 @@ struct SessionDetailView: View {
|
|||||||
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
|
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
|
||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
|
// The jump-to-bottom chevron rides here, immediately above the chat bar — not as a
|
||||||
|
// transcript overlay, which aligned to the scroll view's full-height bounds and so sat
|
||||||
|
// behind this floating bar at the screen's bottom edge. Shown only while scrolled up; a
|
||||||
|
// tap bumps the same scroll request the send button uses, so following resumes once the
|
||||||
|
// transcript reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
|
||||||
|
if !isScrolledToBottom {
|
||||||
|
JumpToBottomButton { scrollToBottomRequest += 1 }
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
|
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
|
||||||
// disabled composer reads as "offline / read-only history" rather than broken.
|
// disabled composer reads as "offline / read-only history" rather than broken.
|
||||||
if !store.connectivity.isLive { disconnectedBanner }
|
if !store.connectivity.isLive { disconnectedBanner }
|
||||||
@@ -243,6 +270,7 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offline notice over the transcript: the chat's history is cached and readable, but the
|
/// The offline notice over the transcript: the chat's history is cached and readable, but the
|
||||||
@@ -277,9 +305,10 @@ struct SessionDetailView: View {
|
|||||||
if approval.toolName == AskUserQuestion.toolName,
|
if approval.toolName == AskUserQuestion.toolName,
|
||||||
let questions = AskUserQuestion.questions(from: approval.input)
|
let questions = AskUserQuestion.questions(from: approval.input)
|
||||||
{
|
{
|
||||||
AskUserQuestionCardView(approval: approval, questions: questions)
|
AskUserQuestionCardView(
|
||||||
|
approval: approval, questions: questions, availableHeight: availableHeight)
|
||||||
} else {
|
} else {
|
||||||
ApprovalCardView(approval: approval)
|
ApprovalCardView(approval: approval, availableHeight: availableHeight)
|
||||||
}
|
}
|
||||||
} else if let status = summary?.status {
|
} else if let status = summary?.status {
|
||||||
let running = status == .running
|
let running = status == .running
|
||||||
@@ -391,6 +420,15 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The transcript's measured height, fed to the floating action card so it can bound itself to the
|
||||||
|
/// screen (see `availableHeight`).
|
||||||
|
private struct AvailableHeightKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat { 0 }
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = max(value, nextValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TranscriptList: View {
|
struct TranscriptList: View {
|
||||||
let events: [AgentEvent]
|
let events: [AgentEvent]
|
||||||
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
|
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
|
||||||
@@ -401,8 +439,9 @@ struct TranscriptList: View {
|
|||||||
|
|
||||||
/// True while the user is parked at (within a hair of) the bottom, so live output keeps
|
/// True while the user is parked at (within a hair of) the bottom, so live output keeps
|
||||||
/// following; once they scroll up it flips false, following stops, and the jump-to-bottom
|
/// following; once they scroll up it flips false, following stops, and the jump-to-bottom
|
||||||
/// chevron appears. Starts true — a freshly opened chat is anchored at the bottom.
|
/// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. Owned by
|
||||||
@State private var isScrolledToBottom = true
|
/// the parent so the chevron can live in the chat bar above the composer.
|
||||||
|
@Binding var isScrolledToBottom: Bool
|
||||||
|
|
||||||
/// True for a short window right after the transcript appears, while it runs its first
|
/// True for a short window right after the transcript appears, while it runs its first
|
||||||
/// layout passes and its events fill in. During it we accept only "at bottom" scroll
|
/// layout passes and its events fill in. During it we accept only "at bottom" scroll
|
||||||
@@ -426,10 +465,22 @@ struct TranscriptList: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 14) {
|
// Eager VStack (not Lazy): the whole transcript is in memory, and eager layout
|
||||||
|
// hands the ScrollView its true, *fixed* total content height up front. A
|
||||||
|
// LazyVStack instead realizes rows as they scroll into view, so its reported
|
||||||
|
// content height shifts mid-scroll as rows resolve — and that shifting height fed
|
||||||
|
// straight back through the geometry reader below (flip at-bottom → toggle the
|
||||||
|
// `.defaultScrollAnchor` → re-pin → new height → …), which is what made the whole
|
||||||
|
// transcript jitter up and down under a drag. A stable height breaks the loop.
|
||||||
|
// Matches the Mac transcript, for the same reason.
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
TranscriptRow(item: item).id(item.id)
|
TranscriptRow(item: item).id(item.id)
|
||||||
}
|
}
|
||||||
|
// Fixed, zero-content scroll target for "jump to bottom". Scrolling to the last
|
||||||
|
// row's id instead would chase a moving target — the final row's identity
|
||||||
|
// changes as its streaming deltas coalesce — nudging the viewport mid-stream.
|
||||||
|
Color.clear.frame(height: 1).id(Self.bottomAnchorID)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
// Cap the transcript to a readable measure on a wide iPad so lines don't run
|
// Cap the transcript to a readable measure on a wide iPad so lines don't run
|
||||||
@@ -483,35 +534,27 @@ struct TranscriptList: View {
|
|||||||
// so every streamed delta keeps the view pinned to the bottom — but only while the
|
// so every streamed delta keeps the view pinned to the bottom — but only while the
|
||||||
// user is already parked there. Scrolling up to read history is never yanked down.
|
// user is already parked there. Scrolling up to read history is never yanked down.
|
||||||
.onChange(of: events.count) {
|
.onChange(of: events.count) {
|
||||||
if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) }
|
if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) }
|
||||||
}
|
|
||||||
// An explicit jump — the chevron or sending a message — always wins.
|
|
||||||
.onChange(of: scrollToBottomRequest) { scrollToLast(proxy) }
|
|
||||||
// Floating chevron above the chat bar, shown only while scrolled up; tap to jump back
|
|
||||||
// to the latest output. Mirrors the Mac's `JumpToBottomButton`.
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
ZStack {
|
|
||||||
if !isScrolledToBottom {
|
|
||||||
JumpToBottomButton { scrollToLast(proxy) }
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
|
|
||||||
}
|
}
|
||||||
|
// An explicit jump — the chevron or sending a message — always wins. The chevron
|
||||||
|
// itself lives in the parent's chat bar (above the composer), not as an overlay here,
|
||||||
|
// so it sits over the composer instead of behind the floating bar; a tap bumps this
|
||||||
|
// same request, and following resumes once the geometry reader sees the bottom.
|
||||||
|
.onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll the last message to the bottom edge; `animated: false` during the open-settle
|
/// Scroll the fixed bottom anchor into view; `animated: false` during the open-settle
|
||||||
/// window so a chat that's still laying out snaps to the tail instead of easing into place.
|
/// window so a chat that's still laying out snaps to the tail instead of easing into place.
|
||||||
private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) {
|
private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
|
||||||
guard let last = items.last else { return }
|
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
|
||||||
} else {
|
} else {
|
||||||
proxy.scrollTo(last.id, anchor: .bottom)
|
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let bottomAnchorID = "nucleic.transcript-bottom"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The floating "jump to the latest" chevron shown above the chat bar while the user has
|
/// The floating "jump to the latest" chevron shown above the chat bar while the user has
|
||||||
|
|||||||
Reference in New Issue
Block a user