From a3bc2bc184594f771af2725d8245d35aca455f0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 01:11:30 +0000 Subject: [PATCH 1/3] feat(ios): show final plan approval as compact composer strip Move plan_approval UI from inline transcript cards to a compact strip directly above the Work chat composer. Tapping opens a large sheet with markdown-rendered plan text; Approve/Reject stay on the collapsed strip. - Add WorkPlanComposerStrip and WorkPlanFullScreenView - Remove WorkPlanReviewCard from live timeline - Plan-specific composer placeholder copy - Tests for timeline omission and placeholder behavior Co-authored-by: Arul Sharma --- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + .../Views/Work/WorkChatRichCardViews.swift | 311 --------------- .../Work/WorkChatSessionView+Timeline.swift | 22 +- .../ADE/Views/Work/WorkChatSessionView.swift | 46 ++- .../Views/Work/WorkPlanComposerViews.swift | 362 ++++++++++++++++++ .../Work/WorkStatusAndFormattingHelpers.swift | 16 + .../ADE/Views/Work/WorkTimelineHelpers.swift | 11 +- apps/ios/ADETests/ADETests.swift | 87 +++++ docs/features/chat/composer-and-ui.md | 1 + 9 files changed, 513 insertions(+), 347 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 49c89b639..4ae394cd6 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000052 /* LaneDeeplinkHelpers.swift */; }; E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000053 /* LaneDetailGitActionsPane.swift */; }; E10000000000000000000047 /* ADEInspectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000047 /* ADEInspectable.swift */; }; + E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000054 /* WorkPlanComposerViews.swift */; }; E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */; }; E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002F /* WorkArtifactTerminalViews.swift */; }; E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000030 /* WorkMarkdownViews.swift */; }; @@ -241,6 +242,7 @@ D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = ""; }; D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = ""; }; D10000000000000000000047 /* ADEInspectable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEInspectable.swift; path = ADE/Debug/ADEInspectorKit/ADEInspectable.swift; sourceTree = ""; }; + D10000000000000000000054 /* WorkPlanComposerViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPlanComposerViews.swift; path = ADE/Views/Work/WorkPlanComposerViews.swift; sourceTree = ""; }; D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatComposerAndInputViews.swift; path = ADE/Views/Work/WorkChatComposerAndInputViews.swift; sourceTree = ""; }; D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = ""; }; D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = ""; }; @@ -613,6 +615,7 @@ D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, + D10000000000000000000054 /* WorkPlanComposerViews.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D1000000000000000000002F /* WorkArtifactTerminalViews.swift */, D10000000000000000000030 /* WorkMarkdownViews.swift */, @@ -1086,6 +1089,7 @@ E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, + E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */, E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */, diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 5c3bfc073..25bc189df 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -1312,317 +1312,6 @@ struct WorkProposedPlanCard: View { } } -/// Plan-review card — shown when the agent enters plan mode and is waiting for -/// the user to approve or reject the implementation plan. Mirrors the desktop -/// `ChatProposedPlanCard` with amber/gold accent to separate it visually from -/// regular chat messages and draw the user's attention. -/// -/// Collapsed state: header + truncated plan preview. -/// Expanded state: full scrollable plan text in a monospace block. -/// Actions: "Approve & Implement" (primary, success tint) and a "Reject & Revise" -/// flow that reveals an optional feedback text field before sending decline. -struct WorkPlanReviewCard: View { - let plan: WorkPendingPlanApprovalModel - let busy: Bool - let onDecision: @MainActor (AgentChatApprovalDecision, String?) async -> Void - /// Provider fallback for when the plan-approval detail carried no `source`. - /// Usually the session provider. - var fallbackProvider: String? = nil - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - /// Whether the full plan body is expanded in the scrollable block. - @State private var planExpanded = false - /// True while the "Reject & Revise" flow is open. - @State private var rejectFlowVisible = false - /// Optional feedback text the user can supply when rejecting. - @State private var feedbackText = "" - - private let collapseThreshold = 400 - - private var shouldOfferExpand: Bool { - plan.planText.count > collapseThreshold - } - - /// Resolved asking provider: the parsed plan source, else the session - /// fallback. Drives the header verb, logo, and per-provider accent. - private var resolvedProvider: String? { - let trimmed = plan.source.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? fallbackProvider : trimmed - } - - /// Per-provider accent (Claude amber, Codex warm white, Cursor/Droid violet, - /// OpenCode blue). Replaces the single hardcoded gold the card used to carry. - private var accent: Color { ADEColor.providerChatAccent(for: resolvedProvider) } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Per-provider accent gradient line at the top — matches desktop's `border-b` gradient. - LinearGradient( - colors: [Color.clear, accent.opacity(0.55), Color.clear], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 1) - - VStack(alignment: .leading, spacing: 14) { - planHeader - planBody - if rejectFlowVisible { - feedbackSection - } - actionRow - } - .padding(16) - } - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(ADEColor.cardBackground.opacity(0.92)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(accent.opacity(0.22), lineWidth: 0.8) - ) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .accessibilityElement(children: .contain) - .accessibilityLabel("\(workChatSurfaceProviderName(resolvedProvider)) · Plan ready. \(plan.title). \(plan.planText.prefix(120))") - } - - // MARK: - Header - - private var planHeader: some View { - HStack(alignment: .center, spacing: 8) { - // Provider-identified header: logo + "{Provider} · Plan ready". Replaces - // the old clock glyph + "PLAN APPROVAL" label from the desktop redesign. - WorkProviderBareLogo( - provider: resolvedProvider, - fallbackSymbol: providerIcon(resolvedProvider ?? ""), - tint: accent, - size: 18 - ) - - Text(plan.providerHeaderVerb(fallbackProvider: fallbackProvider)) - .font(.caption.weight(.semibold)) - .foregroundStyle(accent) - - Spacer(minLength: 8) - - Image(systemName: "list.bullet.clipboard") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(accent.opacity(0.45)) - } - } - - // MARK: - Plan body - - @ViewBuilder - private var planBody: some View { - let displayText = (shouldOfferExpand && !planExpanded) - ? String(plan.planText.prefix(collapseThreshold)) + "…" - : plan.planText - - VStack(alignment: .leading, spacing: 8) { - ScrollView { - Text(displayText) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary.opacity(0.88)) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(maxHeight: planExpanded ? 420 : 220) - .padding(12) - .background( - ADEColor.recessedBackground.opacity(0.75), - in: RoundedRectangle(cornerRadius: 12, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(accent.opacity(0.10), lineWidth: 0.5) - ) - .animation(.spring(duration: 0.28), value: planExpanded) - - if shouldOfferExpand { - HStack(spacing: 0) { - Button { - withAnimation(.spring(duration: 0.25)) { - planExpanded.toggle() - } - } label: { - HStack(spacing: 4) { - Image(systemName: planExpanded ? "arrow.up.left.and.arrow.down.right" : "arrow.down.left.and.arrow.up.right") - .font(.system(size: 9, weight: .bold)) - Text(planExpanded ? "Collapse" : "View full plan") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .tracking(0.6) - } - .foregroundStyle(accent.opacity(0.60)) - } - .buttonStyle(.plain) - .accessibilityLabel(planExpanded ? "Collapse plan" : "View full plan") - - Spacer(minLength: 8) - - // Copy plan button - WorkPlanCopyButton(text: plan.planText, accent: accent) - } - } else { - HStack { - Spacer(minLength: 0) - WorkPlanCopyButton(text: plan.planText, accent: accent) - } - } - } - } - - // MARK: - Reject feedback section - - @ViewBuilder - private var feedbackSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Feedback (optional)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - TextField("Describe what to change…", text: $feedbackText, axis: .vertical) - .lineLimit(2...5) - .adeInsetField(cornerRadius: 12, padding: 10) - .disabled(busy) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // MARK: - Action row - - private var actionRow: some View { - HStack(spacing: 10) { - if !rejectFlowVisible { - // Primary: Approve & Implement - Button { - Task { await onDecision(.accept, nil) } - } label: { - HStack(spacing: 6) { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - Text("Approve & Implement") - .font(.caption.weight(.semibold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.success.opacity(0.82)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.success.opacity(0.40), lineWidth: 0.8) - ) - } - .buttonStyle(.plain) - .disabled(busy) - .accessibilityLabel("Approve plan and begin implementation") - - // Secondary: Reject & Revise - Button { - withAnimation(.spring(duration: 0.22)) { - rejectFlowVisible = true - } - } label: { - Text("Reject & Revise") - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.70)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.8) - ) - } - .buttonStyle(.plain) - .disabled(busy) - .accessibilityLabel("Reject plan and request revisions") - } else { - // Confirm rejection - Button { - let feedback = feedbackText.trimmingCharacters(in: .whitespacesAndNewlines) - Task { await onDecision(.decline, feedback.isEmpty ? nil : feedback) } - } label: { - Text("Send Rejection") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.danger.opacity(0.82)) - ) - } - .buttonStyle(.plain) - .disabled(busy) - .accessibilityLabel("Confirm plan rejection") - - // Cancel rejection flow - Button { - withAnimation(.spring(duration: 0.22)) { - rejectFlowVisible = false - feedbackText = "" - } - } label: { - Text("Cancel") - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.70)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.8) - ) - } - .buttonStyle(.plain) - .disabled(busy) - .accessibilityLabel("Cancel rejection") - } - } - .animation(.spring(duration: 0.22), value: rejectFlowVisible) - } -} - -/// Compact copy-to-clipboard button used in the plan body footer. -private struct WorkPlanCopyButton: View { - let text: String - var accent: Color = ADEColor.warning - @State private var copied = false - - var body: some View { - Button { - UIPasteboard.general.string = text - copied = true - Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_400_000_000) - copied = false - } - } label: { - HStack(spacing: 4) { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .font(.system(size: 9, weight: .bold)) - Text(copied ? "Copied" : "Copy plan") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .tracking(0.6) - } - .foregroundStyle(copied ? ADEColor.success : accent.opacity(0.55)) - } - .buttonStyle(.plain) - .accessibilityLabel(copied ? "Copied to clipboard" : "Copy plan to clipboard") - } -} - /// Horizontal chip strip surfacing running/recently-finished subagents above /// the transcript, so the user can see at a glance what's in flight without /// hunting through the timeline. diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index fda09c995..26888f2dd 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -108,25 +108,9 @@ extension WorkChatSessionView { } } ) - case .pendingPlanApproval(let plan): - WorkPlanReviewCard( - plan: plan, - busy: actionInFlight || !isLive, - onDecision: { decision, feedback in - await runSessionAction { - // Approve: send "accept" decision directly. - // Reject: send "decline"; if the user typed feedback, also - // queue it as a follow-up steer message so the agent sees the - // revision notes in the next turn. - await onApproveRequest(plan.id, decision) - if decision == .decline, let feedback, !feedback.isEmpty { - _ = await onSend(feedback) - } - } - }, - fallbackProvider: chatSummary?.provider - ) - .id("pending-question-\(plan.id)") + case .pendingPlanApproval: + // Final plan approval renders in the composer strip above the prompt. + EmptyView() case .pendingModelSelection(let request): WorkModelSelectionPendingCard( request: request, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 781d6c836..1e2956664 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -170,17 +170,28 @@ struct WorkChatSessionView: View { return primaryPendingInput?.id } - /// Scroll anchor for the first blocking inline pending card. Questions and - /// plan approvals render inline under "pending-question-"; approval gates - /// render in the top overview section, so we just pin to the top for those. + /// Scroll anchor for the first blocking inline pending card. Plan approvals + /// render in the composer strip above the prompt, so they do not scroll. private var blockingPendingScrollAnchor: String? { switch primaryPendingInput { case .question(let model): return "pending-question-\(model.id)" - case .planApproval(let model): return "pending-question-\(model.id)" - case .permission, .modelSelection, .approval, .none: return nil + case .planApproval, .permission, .modelSelection, .approval, .none: return nil } } + var pendingPlanApproval: WorkPendingPlanApprovalModel? { + for input in pendingInputs { + if case .planApproval(let model) = input { + return model + } + } + return nil + } + + private var composerPlaceholderText: String { + workChatComposerPlaceholder(pendingInputs: pendingInputs, sessionStatus: sessionStatus) + } + /// React to a newly-arrived blocking pending input: fire one light haptic and /// elevate the card into view. No-ops when the same gate is already open. @MainActor @@ -523,10 +534,27 @@ struct WorkChatSessionView: View { ) } + if let planApproval = pendingPlanApproval { + WorkPlanComposerStrip( + plan: planApproval, + busy: actionInFlight || !isLive, + onDecision: { decision, feedback in + await runSessionAction { + await onApproveRequest(planApproval.id, decision) + if decision == .decline, let feedback, !feedback.isEmpty { + _ = await onSend(feedback) + } + } + }, + fallbackProvider: chatSummary?.provider + ) + } + WorkChatComposerCard( chatSummary: chatSummary, usageViewModel: workContextUsageViewModel(transcript: transcript, summary: chatSummary), dictationTargetId: "work-chat:\(session.id)", + composerPlaceholder: composerPlaceholderText, pendingInputCount: pendingInputs.count, awaitingInputGate: hasPendingInputGate, canCompose: canCompose, @@ -962,6 +990,7 @@ private struct WorkChatComposerCard: View { let chatSummary: AgentChatSessionSummary? let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String + let composerPlaceholder: String let pendingInputCount: Int let awaitingInputGate: Bool let canCompose: Bool @@ -987,6 +1016,7 @@ private struct WorkChatComposerCard: View { chatSummary: chatSummary, usageViewModel: usageViewModel, dictationTargetId: dictationTargetId, + composerPlaceholder: composerPlaceholder, pendingInputCount: pendingInputCount, awaitingInputGate: awaitingInputGate, canCompose: canCompose, @@ -1035,6 +1065,7 @@ private struct WorkChatComposerDraftInput: View { let chatSummary: AgentChatSessionSummary? let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String + let composerPlaceholder: String let pendingInputCount: Int let awaitingInputGate: Bool let canCompose: Bool @@ -1061,10 +1092,7 @@ private struct WorkChatComposerDraftInput: View { WorkChatComposerTextField( draftState: draftState, canCompose: canCompose, - placeholder: workChatComposerPlaceholder( - pendingInputCount: pendingInputCount, - sessionStatus: awaitingInputGate ? "awaiting-input" : "" - ) + placeholder: composerPlaceholder ) if showInterrupt && draftState.hasSendableText { diff --git a/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift new file mode 100644 index 000000000..d423ac85e --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift @@ -0,0 +1,362 @@ +import SwiftUI +import UIKit + +// MARK: - Shared plan approval helpers + +func workPlanResolvedProvider( + source: String, + fallbackProvider: String? +) -> String? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallbackProvider : trimmed +} + +struct WorkPlanAccentGradient: View { + let accent: Color + + var body: some View { + LinearGradient( + colors: [Color.clear, accent.opacity(0.55), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + } +} + +struct WorkPlanProviderHeader: View { + let plan: WorkPendingPlanApprovalModel + var fallbackProvider: String? = nil + var showsChevron: Bool = false + var chevronRotation: Double = 0 + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + HStack(alignment: .center, spacing: 8) { + WorkProviderBareLogo( + provider: resolvedProvider, + fallbackSymbol: providerIcon(resolvedProvider ?? ""), + tint: accent, + size: 18 + ) + + Text(plan.providerHeaderVerb(fallbackProvider: fallbackProvider)) + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + + Spacer(minLength: 8) + + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .rotationEffect(.degrees(chevronRotation)) + } else { + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accent.opacity(0.45)) + } + } + } +} + +struct WorkPlanCopyButton: View { + let text: String + var accent: Color = ADEColor.warning + @State private var copied = false + + var body: some View { + Button { + UIPasteboard.general.string = text + copied = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_400_000_000) + copied = false + } + } label: { + HStack(spacing: 4) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 9, weight: .bold)) + Text(copied ? "Copied" : "Copy plan") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .tracking(0.6) + } + .foregroundStyle(copied ? ADEColor.success : accent.opacity(0.55)) + } + .buttonStyle(.plain) + .accessibilityLabel(copied ? "Copied to clipboard" : "Copy plan to clipboard") + } +} + +struct WorkPlanRejectFeedbackSection: View { + @Binding var feedbackText: String + let busy: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Feedback (optional)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + TextField("Describe what to change…", text: $feedbackText, axis: .vertical) + .lineLimit(2...5) + .adeInsetField(cornerRadius: 12, padding: 10) + .disabled(busy) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } +} + +struct WorkPlanApprovalActionRow: View { + let busy: Bool + @Binding var rejectFlowVisible: Bool + @Binding var feedbackText: String + let onDecision: @MainActor (AgentChatApprovalDecision, String?) async -> Void + + var body: some View { + HStack(spacing: 10) { + if !rejectFlowVisible { + Button { + Task { await onDecision(.accept, nil) } + } label: { + HStack(spacing: 6) { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + Text("Approve & Implement") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ADEColor.success.opacity(0.82)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.success.opacity(0.40), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Approve plan and begin implementation") + + Button { + withAnimation(.spring(duration: 0.22)) { + rejectFlowVisible = true + } + } label: { + Text("Reject & Revise") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ADEColor.surfaceBackground.opacity(0.70)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Reject plan and request revisions") + } else { + Button { + let feedback = feedbackText.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await onDecision(.decline, feedback.isEmpty ? nil : feedback) } + } label: { + Text("Send Rejection") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ADEColor.danger.opacity(0.82)) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Confirm plan rejection") + + Button { + withAnimation(.spring(duration: 0.22)) { + rejectFlowVisible = false + feedbackText = "" + } + } label: { + Text("Cancel") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ADEColor.surfaceBackground.opacity(0.70)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Cancel rejection") + } + } + .animation(.spring(duration: 0.22), value: rejectFlowVisible) + } +} + +// MARK: - Composer strip (collapsed) + +/// Compact plan-approval strip above the Work chat composer. Tap the header or +/// preview line to open the full-screen plan reader; approve/reject stay here. +struct WorkPlanComposerStrip: View { + let plan: WorkPendingPlanApprovalModel + let busy: Bool + let onDecision: @MainActor (AgentChatApprovalDecision, String?) async -> Void + var fallbackProvider: String? = nil + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var isPlanExpanded = false + @State private var rejectFlowVisible = false + @State private var feedbackText = "" + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + WorkPlanAccentGradient(accent: accent) + + VStack(alignment: .leading, spacing: 10) { + expandTrigger + + if rejectFlowVisible { + WorkPlanRejectFeedbackSection(feedbackText: $feedbackText, busy: busy) + } + + WorkPlanApprovalActionRow( + busy: busy, + rejectFlowVisible: $rejectFlowVisible, + feedbackText: $feedbackText, + onDecision: onDecision + ) + } + .padding(12) + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ADEColor.cardBackground.opacity(0.92)) + ) + .glassEffect(in: .rect(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(0.22), lineWidth: 0.8) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel( + "\(workChatSurfaceProviderName(resolvedProvider)) · Plan ready. Tap to review the full plan." + ) + .sheet(isPresented: $isPlanExpanded) { + WorkPlanFullScreenView( + plan: plan, + fallbackProvider: fallbackProvider, + onDismiss: { isPlanExpanded = false } + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + + private var expandTrigger: some View { + Button { + withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + isPlanExpanded = true + } + } label: { + VStack(alignment: .leading, spacing: 4) { + WorkPlanProviderHeader( + plan: plan, + fallbackProvider: fallbackProvider, + showsChevron: true, + chevronRotation: isPlanExpanded ? 90 : 0 + ) + + Text("Tap to review the full plan") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("Opens the full plan in a sheet.") + } +} + +// MARK: - Full-screen plan reader + +struct WorkPlanFullScreenView: View { + let plan: WorkPendingPlanApprovalModel + var fallbackProvider: String? = nil + let onDismiss: () -> Void + + @Environment(\.dismiss) private var dismiss + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + NavigationStack { + ScrollView { + WorkMarkdownRenderer(markdown: plan.planText) + .textSelection(.enabled) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .background(ADEColor.pageBackground) + .navigationTitle(plan.providerHeaderVerb(fallbackProvider: fallbackProvider)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + onDismiss() + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + } + .accessibilityLabel("Close plan") + } + ToolbarItem(placement: .topBarTrailing) { + WorkPlanCopyButton(text: plan.planText, accent: accent) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Full plan. \(plan.planText.prefix(120))") + } +} diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 854bda780..b90e96ff3 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -56,6 +56,22 @@ func workChatComposerPlaceholder(pendingInputCount: Int, sessionStatus: String) return "Type to vibecode..." } +func workChatComposerPlaceholder( + pendingInputs: [WorkPendingInputItem], + sessionStatus: String +) -> String { + if workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) { + return "Waiting for prompt details..." + } + if pendingInputs.count == 1, case .planApproval = pendingInputs[0] { + return "Review the plan above..." + } + return workChatComposerPlaceholder( + pendingInputCount: pendingInputs.count, + sessionStatus: sessionStatus + ) +} + func terminalSessionHasResumeTarget(_ session: TerminalSessionSummary) -> Bool { if session.resumeMetadata != nil { return true diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 27f2cc509..80086afdd 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -268,14 +268,9 @@ func buildWorkTimeline( rank: 1_600 + index, payload: .pendingPermission(model) ) - case .planApproval(let model): - let ts = pendingTimestamps[model.id] ?? fallbackPendingTimestamp - return WorkTimelineEntry( - id: "pending-plan-approval-\(model.id)", - timestamp: ts, - rank: 1_600 + index, - payload: .pendingPlanApproval(model) - ) + case .planApproval: + // Composer strip owns live plan-approval UI; keep it out of the transcript. + return nil case .modelSelection(let model): let ts = pendingTimestamps[model.id] ?? fallbackPendingTimestamp return WorkTimelineEntry( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 7ee23a590..6ef64e5c5 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -8159,6 +8159,93 @@ final class ADETests: XCTestCase { ) } + func testBuildWorkTimelineOmitsPendingPlanApprovalFromTranscript() { + let detail = """ + { + "request": { + "kind": "plan_approval", + "source": "claude", + "title": "Implementation plan", + "description": "## Plan\\nShip the composer strip." + } + } + """ + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .approvalRequest( + description: "Plan ready", + detail: detail, + itemId: "plan-approval-1", + turnId: "turn-1" + ) + ), + ] + + let inputs = derivePendingWorkInputs(from: transcript) + guard case .planApproval(let plan) = inputs.first else { + return XCTFail("Expected plan approval pending input.") + } + XCTAssertEqual(plan.id, "plan-approval-1") + + let timeline = buildWorkTimeline( + transcript: transcript, + fallbackEntries: [], + toolCards: [], + commandCards: [], + fileChangeCards: [], + eventCards: [], + pendingInputs: inputs, + artifacts: [], + localEchoMessages: [] + ) + XCTAssertFalse( + timeline.contains(where: { + if case .pendingPlanApproval = $0.payload { return true } + return false + }), + "Plan approval should render in the composer strip, not the transcript timeline." + ) + } + + func testWorkChatComposerPlaceholderUsesPlanReviewCopyForPlanApprovalOnly() { + let plan = WorkPendingPlanApprovalModel( + id: "plan-1", + source: "claude", + planText: "## Plan\nDo the thing.", + title: "Implementation plan" + ) + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputs: [.planApproval(plan)], sessionStatus: "idle"), + "Review the plan above..." + ) + XCTAssertEqual( + workChatComposerPlaceholder( + pendingInputs: [ + .planApproval(plan), + .question( + WorkPendingQuestionModel( + id: "q-1", + questions: [ + WorkPendingQuestion( + questionId: "q-1", + question: "Pick one?", + options: [], + allowsFreeform: true + ) + ], + source: "claude" + ) + ), + ], + sessionStatus: "idle" + ), + "Answer the prompt above..." + ) + } + func testWorkChatStatusNormalizationFallsBackToSessionRuntimeStateAndTerminalState() { let completedSummary = makeAgentChatSessionSummary(status: "completed", awaitingInput: false) XCTAssertEqual(normalizedWorkChatSessionStatus(session: nil, summary: completedSummary), "ended") diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 23e7db419..594021ecf 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -35,6 +35,7 @@ stream plus session metadata. | `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The PR action opens or toggles a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. Opening the chat PR pane or compact PR menu performs a targeted, cooldown-bound refresh for that single linked PR. | | `ChatPrPane.tsx` | Left floating PR pane for Work chat. Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. | | `ChatProposedPlanCard.tsx` | Composer-level plan approval card shown while input is locked. Renders the plan description or question text as rich markdown (`ChatMarkdown`) inside a scrollable container (capped at `min(34vh, 360px)`). Transcript plan events render through `AgentChatMessageList` / `CodexPlanCard`. | +| iOS `WorkPlanComposerStrip` | iOS final plan approval (`plan_approval`) renders as a compact strip above the Work chat composer. Tap opens a large sheet with markdown plan body; Approve/Reject stay on the collapsed strip. | | `ChatModelSelectionPendingCard.tsx` | Full agent-briefing model picker for orchestration pending inputs. Shows description, touched files, run-after dependencies, provider/model controls, and submitting/cancel states without a recommended default model. | | `codex/CodexPlanCard.tsx` | Codex plan card rendered inline in the transcript for `plan` events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via `ChatMarkdown`. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing `steps` arrays gracefully. | | `codex/CodexGoalCard.tsx`, `codex/CodexGoalBanner.tsx` | Codex goal surfaces. The card is the active desktop surface and routes edits/clears through typed ADE APIs (`ade.agentChat.codex.*`) rather than prompt text. It shows objective, status, token count, and elapsed time, while hiding provider budgets because ADE keeps goals unlimited. The banner remains available for compact surfaces that need a horizontal goal strip. | From 98c8bf0974df62b5ed59aa01e678ca8e57761bae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 01:14:11 +0000 Subject: [PATCH 2/3] fix(ios): route plan rejection feedback through chat.approve responseText Quality pass: pass decline feedback on the approval RPC instead of a separate chat.send, reset haptic state on session switch, and stabilize plan strip identity with .id(plan.id). Co-authored-by: Arul Sharma --- apps/ios/ADE/Views/Work/WorkChatSessionView.swift | 15 +++++++++------ apps/ios/ADE/Views/Work/WorkPreviews.swift | 2 +- .../Work/WorkSessionDestinationView+Actions.swift | 10 ++++++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 1e2956664..7738f0c36 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -53,7 +53,7 @@ struct WorkChatSessionView: View { let onOpenLane: (() -> Void)? let onSend: @MainActor (String) async -> Bool let onInterrupt: @MainActor () async -> Void - let onApproveRequest: @MainActor (String, AgentChatApprovalDecision) async -> Void + let onApproveRequest: @MainActor (String, AgentChatApprovalDecision, String?) async -> Void let onRespondToQuestion: @MainActor (String, String, AgentChatInputAnswerValue?, String?) async -> Void let onSubmitQuestionAnswers: @MainActor (String, [String: AgentChatInputAnswerValue], String?) async -> Void let onDeclineQuestion: @MainActor (String) async -> Void @@ -341,6 +341,9 @@ struct WorkChatSessionView: View { return "Reconnect to send messages." } if !pendingInputs.isEmpty { + if pendingInputs.count == 1, case .planApproval = pendingInputs[0] { + return "Review the plan above the composer, or reject it before sending another message." + } return "Answer the waiting prompt above, or decline it before sending another message." } if awaitingPromptDetailsMissing { @@ -382,7 +385,7 @@ struct WorkChatSessionView: View { busy: actionInFlight, onDecision: { decision in await runSessionAction { - await onApproveRequest(approval.id, decision) + await onApproveRequest(approval.id, decision, nil) } } ) @@ -540,14 +543,12 @@ struct WorkChatSessionView: View { busy: actionInFlight || !isLive, onDecision: { decision, feedback in await runSessionAction { - await onApproveRequest(planApproval.id, decision) - if decision == .decline, let feedback, !feedback.isEmpty { - _ = await onSend(feedback) - } + await onApproveRequest(planApproval.id, decision, feedback) } }, fallbackProvider: chatSummary?.provider ) + .id(planApproval.id) } WorkChatComposerCard( @@ -785,6 +786,8 @@ struct WorkChatSessionView: View { pendingCodexFastMode = nil composerSettingMutationInFlight = false composerSettingMutationGeneration &+= 1 + lastBlockingPendingInputId = nil + blockingPendingHapticToken = 0 } .onChange(of: transcript) { _, _ in scheduleTimelineSnapshotRebuild() diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index a329325f5..2ba8464b1 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -451,7 +451,7 @@ private enum WorkPreviewData { onOpenLane: {}, onSend: { _ in true }, onInterrupt: {}, - onApproveRequest: { _, _ in }, + onApproveRequest: { _, _, _ in }, onRespondToQuestion: { _, _, _, _ in }, onSubmitQuestionAnswers: { _, _, _ in }, onDeclineQuestion: { _ in }, diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 57feb2bbc..2ddca4d76 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -85,9 +85,15 @@ extension WorkSessionDestinationView { } @MainActor - func approveRequest(itemId: String, decision: AgentChatApprovalDecision) async { + func approveRequest(itemId: String, decision: AgentChatApprovalDecision, responseText: String? = nil) async { do { - try await syncService.approveChatSession(sessionId: sessionId, itemId: itemId, decision: decision) + let trimmed = responseText?.trimmingCharacters(in: .whitespacesAndNewlines) + try await syncService.approveChatSession( + sessionId: sessionId, + itemId: itemId, + decision: decision, + responseText: (trimmed?.isEmpty ?? true) ? nil : trimmed + ) await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { From f69e00dc633f28e0afb99a8bc34771d1643f690e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 01:14:15 +0000 Subject: [PATCH 3/3] docs: update iOS plan approval component name Co-authored-by: Arul Sharma --- docs/features/chat/composer-and-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 594021ecf..97fda077e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -582,7 +582,7 @@ meaningful content rather than a generic label. The card's data contract (`PendingInputRequest` / `PendingInputQuestion` / `PendingInputOption` in `shared/types/chat.ts`) is the single source of truth: the TUI (`apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx`) -and iOS (`WorkStructuredQuestionCard` / `WorkPlanReviewCard`) render the +and iOS (`WorkStructuredQuestionCard` / `WorkPlanComposerStrip`) render the same header verb, dedup, monospace preview, and per-provider accent. The verb/name helpers live in `shared/pendingInputLabels.ts` so desktop and TUI share them; iOS mirrors them in Swift. A blocking pending input also