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()) diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift index 18e55c5..4a81684 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] = [:] @@ -35,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) } } @@ -89,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 diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2b0ef81..7bbcefd 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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 // 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 + // 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 } } 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 // in a boxed strip below it, so the transcript runs the full height of the screen. .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. 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 } @@ -243,6 +270,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 @@ -277,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 @@ -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 { let events: [AgentEvent] /// 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 /// 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 @@ -426,10 +465,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 @@ -483,35 +534,27 @@ 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) } } } - /// 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