From f7dad34fb9f27d8831464c233f4eadab9477ffda Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:31:43 -0700 Subject: [PATCH 01/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2b0ef81..d842df6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -18,6 +18,9 @@ struct SessionDetailView: View { // 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). @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 private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } From 07a0db2e29bbca6206fb9b10a703a31f1bf4078c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:31:47 -0700 Subject: [PATCH 02/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index d842df6..0532ff6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -27,7 +27,9 @@ struct SessionDetailView: View { } var body: some View { - TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest) + TranscriptList(events: store.openEvents, + scrollToBottomRequest: scrollToBottomRequest, + isScrolledToBottom: $isScrolledToBottom) // 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. .safeAreaInset(edge: .bottom) { actionArea } From 07382aefd0e245207163dc541dfb567c4924754a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:31:56 -0700 Subject: [PATCH 03/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 0532ff6..7c2595d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -241,6 +241,15 @@ struct SessionDetailView: View { /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. private var actionArea: some View { 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 // disabled composer reads as "offline / read-only history" rather than broken. if !store.connectivity.isLive { disconnectedBanner } @@ -248,6 +257,7 @@ struct SessionDetailView: View { } .padding(.horizontal, 12) .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 From 584ae10622d808b06b080f59cb2937dc832ae0c7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:32:02 -0700 Subject: [PATCH 04/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 7c2595d..84bfdd8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -416,8 +416,9 @@ struct TranscriptList: View { /// 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 - /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. - @State private var isScrolledToBottom = true + /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. Owned by + /// 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 /// layout passes and its events fill in. During it we accept only "at bottom" scroll From cbd155fc874167dd9339bd303fb3aab3d88653b7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:32:34 -0700 Subject: [PATCH 05/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 84bfdd8..c1d5d9e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -442,10 +442,22 @@ struct TranscriptList: View { var body: some View { ScrollViewReader { proxy in 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 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() // Cap the transcript to a readable measure on a wide iPad so lines don't run From c20270988af8465180489a1fee17ea5e1ff9e8df Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:32:44 -0700 Subject: [PATCH 06/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index c1d5d9e..d45693c 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -511,22 +511,13 @@ struct TranscriptList: View { // 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. .onChange(of: events.count) { - if isScrolledToBottom { scrollToLast(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) + if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } } + // 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) } } } From 3a82a8840a110fc33b1ed1c559c56ce7dfb5c085 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 03:32:53 -0700 Subject: [PATCH 07/15] Session Chat Auto-Scroll Adjustment Nucleic-Session: 8FCEB1B7-A6C0-44E2-B00A-E4786F41A1FA Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index d45693c..60bac70 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -521,16 +521,17 @@ struct TranscriptList: View { } } - /// 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. - private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) { - guard let last = items.last else { return } + private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) { if animated { - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } 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 From f2637c2cc8fe44958d93b65bfaff0308e191f720 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:21:04 -0700 Subject: [PATCH 08/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 60bac70..bf20f89 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -21,6 +21,10 @@ struct SessionDetailView: View { // 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? { store.sessions.first { $0.sessionID == sessionID } From 3ffaf0b942bce5b6b3a08c330e37f657e0333928 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:21:14 -0700 Subject: [PATCH 09/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index bf20f89..4825133 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -34,6 +34,15 @@ struct SessionDetailView: View { 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 // in a boxed strip below it, so the transcript runs the full height of the screen. .safeAreaInset(edge: .bottom) { actionArea } From 1cb477ccf796cc6cf20bf80be56801d40df15cad Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:21:20 -0700 Subject: [PATCH 10/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SessionDetailView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4825133..38231a2 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -305,9 +305,10 @@ struct SessionDetailView: View { if approval.toolName == AskUserQuestion.toolName, let questions = AskUserQuestion.questions(from: approval.input) { - AskUserQuestionCardView(approval: approval, questions: questions) + AskUserQuestionCardView( + approval: approval, questions: questions, availableHeight: availableHeight) } else { - ApprovalCardView(approval: approval) + ApprovalCardView(approval: approval, availableHeight: availableHeight) } } else if let status = summary?.status { let running = status == .running From 7e453c6506e638b9701444c5451196fb08376d21 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:21:39 -0700 Subject: [PATCH 11/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SessionDetailView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 38231a2..7bbcefd 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -420,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 { let events: [AgentEvent] /// Bumped by the parent when the user sends a message — a deliberate "show me what happens From 083a2cdd76d472dc4cce563397491c706510daf1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:22:16 -0700 Subject: [PATCH 12/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../Views/ApprovalCardView.swift | 134 ++++++++++++++---- 1 file changed, 105 insertions(+), 29 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index c833396..1d3d709 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -8,6 +8,9 @@ import NucleicProtocol struct ApprovalCardView: View { @EnvironmentObject var store: RemoteStore 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 @@ -27,22 +30,14 @@ struct ApprovalCardView: View { .background(approval.risk.color.opacity(0.2), in: Capsule()) .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". - // Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every - // other tool shows its untruncated detail in a single scrollable box. - if let command = approval.input["command"]?.stringValue, - let commit = GitCommitSummary.parse(command) { - GitCommitCard(commit: commit, rawCommand: command) - } 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) + + // The detail (commit card, host breakdown + command, or plain text) scrolls inside a + // bounded region so a giant command can't grow the card off-screen; the buttons below + // stay pinned and always tappable. Rendered only when there's detail to show. + if hasDetail { + BoundedCardScroll(maxHeight: middleMaxHeight) { + VStack(alignment: .leading, spacing: 10) { detailContent } + } } HStack(spacing: 10) { @@ -83,6 +78,73 @@ struct ApprovalCardView: View { } .padding() .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 { @@ -94,33 +156,47 @@ struct ApprovalCardView: View { } } -/// The full content of an approval, wrapped and selectable. The box sizes to its -/// content and only begins scrolling once the content exceeds `maxHeight`, so a short -/// command sits in a snug box while a large paste stays bounded. The text wraps; it -/// never scrolls horizontally. +/// The full content of an approval, wrapped and selectable. It no longer scrolls on its own — the +/// enclosing ``BoundedCardScroll`` owns the scrolling for the whole card — so it simply sizes to +/// its (already length-capped) text. The text wraps; it never scrolls horizontally. private struct ApprovalDetailBox: View { let text: String - private let maxHeight: CGFloat = 200 + + var body: some View { + Text(text) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) + } +} + +/// 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: View { + let maxHeight: CGFloat + @ViewBuilder var content: Content @State private var contentHeight: CGFloat = 0 var body: some View { ScrollView(.vertical) { - Text(text) - .font(.caption.monospaced()) - .textSelection(.enabled) + content .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) .background(GeometryReader { geo in - Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height) + Color.clear.preference(key: CardContentHeightKey.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)) + .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(CardContentHeightKey.self) { contentHeight = $0 } } } -private struct ApprovalDetailHeightKey: PreferenceKey { +struct CardContentHeightKey: PreferenceKey { static var defaultValue: CGFloat { 0 } static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) From 888a1a406d089996673987c597d895f73f898264 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:22:23 -0700 Subject: [PATCH 13/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../NucleicRemote/Views/AskUserQuestionCardView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift index 18e55c5..7543435 100644 --- a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -14,6 +14,10 @@ struct AskUserQuestionCardView: View { @EnvironmentObject var store: RemoteStore let approval: ApprovalRequest 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. @State private var selected: [String: Set] = [:] From 9d350d49248c8b54948f2cacd792ac9dbbf21e93 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:22:33 -0700 Subject: [PATCH 14/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../Views/AskUserQuestionCardView.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift index 7543435..fdcf16f 100644 --- a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -39,20 +39,22 @@ struct AskUserQuestionCardView: View { } if let question = currentQuestion { - VStack(alignment: .leading, spacing: 8) { - if questions.count > 1 { - Text("Question \(step + 1) of \(questions.count)") - .font(.caption).foregroundStyle(.secondary) + BoundedCardScroll(maxHeight: middleMaxHeight) { + VStack(alignment: .leading, spacing: 8) { + if questions.count > 1 { + Text("Question \(step + 1) of \(questions.count)") + .font(.caption).foregroundStyle(.secondary) + } + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption).bold().foregroundStyle(.secondary) + } + Text(question.question).font(.headline) + ForEach(question.options) { option in + optionRow(question, label: option.label, description: option.description) + } + otherRow(question) } - if let header = question.header, !header.isEmpty { - Text(header.uppercased()) - .font(.caption).bold().foregroundStyle(.secondary) - } - Text(question.question).font(.headline) - ForEach(question.options) { option in - optionRow(question, label: option.label, description: option.description) - } - otherRow(question) } } From bc5b9928f81f6a077da4c3151e03e79ec9647a22 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:22:41 -0700 Subject: [PATCH 15/15] Truncation Logic Refinement Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic --- .../NucleicRemote/Views/AskUserQuestionCardView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift index fdcf16f..4a81684 100644 --- a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -95,6 +95,17 @@ struct AskUserQuestionCardView: View { } .padding() .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