Merge nucleic/jolly-quartz-toad-vbsw into dev

This commit is contained in:
2026-07-29 16:47:30 -07:00
parent e114c7ed6d
commit 56023a9e93
2 changed files with 114 additions and 3 deletions
@@ -162,6 +162,110 @@ struct ApprovalCardView: View {
}
}
/// Claude's plan-mode exit is a review, not an ordinary permission gate. It always pauses even
/// when Auto is enabled and offers the same three outcomes as Claude's first-party surface:
/// accept the plan, deny it and remain in plan mode, or return concrete revision feedback.
struct ExitPlanModeApprovalCardView: View {
@EnvironmentObject var store: RemoteStore
let approval: ApprovalRequest
let plan: ExitPlanMode.Plan?
var availableHeight: CGFloat = 0
@State private var revising = false
@State private var feedback = ""
private var canRespond: Bool { store.connectivity.isLive }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 7) {
Image(systemName: "list.bullet.clipboard").foregroundStyle(Palette.accent)
Text("Review plan").font(.subheadline.weight(.semibold))
}
if let plan {
BoundedCardScroll(maxHeight: middleMaxHeight) {
ExitPlanModeCard(plan: plan)
}
} else {
// Claude versions that don't include the plan Markdown in the permission payload
// still need the three-way review. State that plainly instead of degrading to the
// generic permission card (which would incorrectly offer Allow Always).
Text("Claude is ready to leave plan mode and begin implementation.")
.font(.footnote).foregroundStyle(.secondary)
}
if revising {
VStack(alignment: .leading, spacing: 6) {
Text("What should Claude change?")
.font(.footnote.weight(.semibold))
TextField(
"Describe the changes you want in the plan…",
text: $feedback,
axis: .vertical)
.lineLimit(2...5)
.textFieldStyle(.roundedBorder)
}
HStack(spacing: 10) {
Button("Cancel") {
feedback = ""
revising = false
}
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
Button("Send Revision") { submitRevision() }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
.disabled(!canRespond || ExitPlanMode.revisionReason(feedback: feedback) == nil)
.keyboardShortcut(.defaultAction)
}
} else {
HStack(spacing: 8) {
Button(role: .destructive) {
store.respond(approval, .deny(reason: ExitPlanMode.rejectionReason))
} label: {
Text("Deny").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(!canRespond)
.keyboardShortcut(.cancelAction)
Button("Revise") { revising = true }
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
Button {
store.respond(approval, .allow())
} label: {
Text("Accept").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!canRespond)
.keyboardShortcut(.defaultAction)
}
}
}
.padding()
.glassSurface(cornerRadius: 20)
.frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom)
.onChange(of: approval.id) { _, _ in
revising = false
feedback = ""
}
}
private var middleMaxHeight: CGFloat {
let base = availableHeight > 0 ? availableHeight - 12 : 640
return max(140, base - (revising ? 300 : 210))
}
private func submitRevision() {
guard let reason = ExitPlanMode.revisionReason(feedback: feedback) else { return }
store.respond(approval, .deny(reason: reason))
}
}
/// 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.
@@ -440,6 +440,11 @@ struct SessionDetailView: View {
{
AskUserQuestionCardView(
approval: approval, questions: questions, availableHeight: availableHeight)
} else if approval.toolName == ExitPlanMode.toolName {
ExitPlanModeApprovalCardView(
approval: approval,
plan: ExitPlanMode.plan(from: approval.input),
availableHeight: availableHeight)
} else {
ApprovalCardView(approval: approval, availableHeight: availableHeight)
// So a host_exec approval's working-directory line abbreviates the "cd"
@@ -648,9 +653,11 @@ struct SessionDetailView: View {
// approval card in that state, but a resolved-but-not-yet-cleared gate can flash through
// here — say what's actually being waited on rather than claiming the agent is working.
if let approval = store.openApprovals.first {
return approval.toolName == AskUserQuestion.toolName
? "Waiting for answers…"
: "Waiting for approval…"
switch approval.toolName {
case AskUserQuestion.toolName: return "Waiting for answers…"
case ExitPlanMode.toolName: return "Waiting for plan review…"
default: return "Waiting for approval…"
}
}
for event in store.openEvents.reversed() {
// Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events