Merge branch 'nucleic/trunk' into dev

# Conflicts:
#	Tests/NucleicCoreTests/MacVMTests.swift
#	ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
This commit is contained in:
2026-07-06 10:48:56 -07:00
3 changed files with 160 additions and 44 deletions
@@ -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
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<Content: View>: View {
let maxHeight: CGFloat
@ViewBuilder var content: Content
@State private var contentHeight: CGFloat = 0 @State private var contentHeight: CGFloat = 0
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
Text(text) content
.font(.caption.monospaced())
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(GeometryReader { geo in .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)) .frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
.onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 } .scrollBounceBehavior(.basedOnSize)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) .onPreferenceChange(CardContentHeightKey.self) { contentHeight = $0 }
} }
} }
private struct ApprovalDetailHeightKey: PreferenceKey { 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,20 +39,22 @@ struct AskUserQuestionCardView: View {
} }
if let question = currentQuestion { if let question = currentQuestion {
VStack(alignment: .leading, spacing: 8) { BoundedCardScroll(maxHeight: middleMaxHeight) {
if questions.count > 1 { VStack(alignment: .leading, spacing: 8) {
Text("Question \(step + 1) of \(questions.count)") if questions.count > 1 {
.font(.caption).foregroundStyle(.secondary) 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() .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
@@ -21,6 +21,10 @@ struct SessionDetailView: View {
// Owned here (not inside `TranscriptList`) so the jump-to-bottom chevron can ride in the chat // 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. // bar just above the composer. `TranscriptList` drives it from the scroll geometry.
@State private var isScrolledToBottom = true @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 }
@@ -30,6 +34,15 @@ struct SessionDetailView: View {
TranscriptList(events: store.openEvents, TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest, scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom) 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 }
@@ -292,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
@@ -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 { 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