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 60bac70..7bbcefd 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 } @@ -30,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 } @@ -292,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 @@ -406,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