Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -241,6 +242,7 @@
D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = "<group>"; };
D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = "<group>"; };
D10000000000000000000047 /* ADEInspectable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEInspectable.swift; path = ADE/Debug/ADEInspectorKit/ADEInspectable.swift; sourceTree = "<group>"; };
D10000000000000000000054 /* WorkPlanComposerViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPlanComposerViews.swift; path = ADE/Views/Work/WorkPlanComposerViews.swift; sourceTree = "<group>"; };
D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatComposerAndInputViews.swift; path = ADE/Views/Work/WorkChatComposerAndInputViews.swift; sourceTree = "<group>"; };
D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = "<group>"; };
D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
311 changes: 0 additions & 311 deletions apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 3 additions & 19 deletions apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading