Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,34 +55,42 @@ extension NewChatViewController {
}
}

// Use onDismiss so manual ✕ tap goes through the VC and restores inset
card.onDismiss = { [weak self] in
self?.removeProposalCard()
}

proposalCard = card

NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.2
card.animator().alphaValue = 1
}

// Adjust scroll view inset to avoid overlap
// Adjust scroll view inset to avoid overlap — store exact amount for precise restoration
card.layoutSubtreeIfNeeded()
let cardHeight = card.fittingSize.height + 8
chatScrollView.contentInsets.bottom += max(cardHeight, 80)
let inset = max(cardHeight, 80)
proposalCardInset = inset
chatScrollView.contentInsets.bottom += inset
}

func handleProposalActioned(result: AIAgentManager.ApprovalResult?, error: String?) {
guard let card = proposalCard else { return }
if let result = result {
// Success: show stamp, card auto-dismisses via onDismiss after 3s
card.showStamp(approved: result.approved)
} else {
card.resetToActionable()
card.showError(error ?? "The request could not be completed. Please try again.")
// Failure: show inline error and re-enable buttons
card.showError(error ?? "Something went wrong. Please try again.")
}
}

func removeProposalCard() {
if let card = proposalCard {
let cardHeight = card.fittingSize.height + 8
chatScrollView.contentInsets.bottom = max(0, chatScrollView.contentInsets.bottom - max(cardHeight, 80))
card.removeFromSuperview()
if proposalCard != nil {
chatScrollView.contentInsets.bottom = max(0, chatScrollView.contentInsets.bottom - proposalCardInset)
proposalCard?.removeFromSuperview()
proposalCardInset = 0
}
proposalCard = nil
}
Expand Down Expand Up @@ -182,6 +190,10 @@ extension NewChatViewController {
outgoing.chat = chat
chat.setLastMessage(outgoing)
CoreDataManager.sharedManager.saveContext()

// Dismiss any visible proposal card when the user sends a new message
removeProposalCard()

completion(true)
// showAgentProcessingBar()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class NewChatViewController: DashboardSplittedViewController {
var agentBarHeightConstraint: NSLayoutConstraint?
var agentProcessingBarTimer: Timer?
var proposalCard: ProposalApprovalCardView?
var proposalCardInset: CGFloat = 0

var contactResultsController: NSFetchedResultsController<UserContact>!
var chatResultsController: NSFetchedResultsController<Chat>!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ final class ProposalApprovalCardView: NSView {
// MARK: - Callbacks
var onApprove: ((String) -> Void)?
var onReject: ((String) -> Void)?
var onDismiss: (() -> Void)?

// MARK: - State
private let proposalId: String
private var isActioned: Bool = false
private(set) var isActioned: Bool = false
private var isLoading: Bool = false

// MARK: - Subviews

Expand All @@ -35,8 +37,8 @@ final class ProposalApprovalCardView: NSView {

private let titleLabel: NSTextField = {
let tf = NSTextField(labelWithString: "")
tf.font = NSFont.boldSystemFont(ofSize: 13)
tf.textColor = NSColor.labelColor
tf.font = NSFont(name: "Roboto-Bold", size: 13) ?? NSFont.boldSystemFont(ofSize: 13)
tf.textColor = NSColor.Sphinx.Text
tf.lineBreakMode = .byWordWrapping
tf.maximumNumberOfLines = 2
tf.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -47,8 +49,8 @@ final class ProposalApprovalCardView: NSView {

private let descLabel: NSTextField = {
let tf = NSTextField(labelWithString: "")
tf.font = NSFont.systemFont(ofSize: 11)
tf.textColor = NSColor.secondaryLabelColor
tf.font = NSFont(name: "Roboto-Regular", size: 11) ?? NSFont.systemFont(ofSize: 11)
tf.textColor = NSColor.Sphinx.SecondaryText
tf.lineBreakMode = .byWordWrapping
tf.maximumNumberOfLines = 3
tf.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -59,24 +61,31 @@ final class ProposalApprovalCardView: NSView {

private let approveButton: NSButton = {
let btn = NSButton(title: "Approve", target: nil, action: nil)
btn.bezelStyle = .rounded
btn.wantsLayer = true
btn.isBordered = false
btn.layer?.backgroundColor = NSColor.Sphinx.PrimaryBlue.cgColor
btn.layer?.cornerRadius = 8
btn.contentTintColor = .white
btn.font = NSFont(name: "Roboto-Medium", size: 12) ?? NSFont.systemFont(ofSize: 12, weight: .medium)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}()

private let rejectButton: NSButton = {
let btn = NSButton(title: "Reject", target: nil, action: nil)
btn.bezelStyle = .rounded
btn.wantsLayer = true
btn.isBordered = false
btn.layer?.backgroundColor = NSColor.Sphinx.PrimaryBlue.withAlphaComponent(0.15).cgColor
btn.layer?.cornerRadius = 8
btn.contentTintColor = NSColor.Sphinx.PrimaryBlue
btn.font = NSFont(name: "Roboto-Medium", size: 12) ?? NSFont.systemFont(ofSize: 12, weight: .medium)
btn.translatesAutoresizingMaskIntoConstraints = false
if #available(macOS 10.14, *) {
btn.contentTintColor = NSColor.systemRed
}
return btn
}()

private let stampLabel: NSTextField = {
let tf = NSTextField(labelWithString: "")
tf.font = NSFont.boldSystemFont(ofSize: 14)
tf.font = NSFont(name: "Roboto-Bold", size: 14) ?? NSFont.boldSystemFont(ofSize: 14)
tf.translatesAutoresizingMaskIntoConstraints = false
tf.isEditable = false
tf.isSelectable = false
Expand All @@ -90,11 +99,34 @@ final class ProposalApprovalCardView: NSView {
btn.bezelStyle = .inline
btn.isBordered = false
btn.font = NSFont.systemFont(ofSize: 11)
btn.contentTintColor = NSColor.secondaryLabelColor
btn.contentTintColor = NSColor.Sphinx.SecondaryText
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}()

private let spinner: NSProgressIndicator = {
let s = NSProgressIndicator()
s.style = .spinning
s.controlSize = .small
s.isIndeterminate = true
s.isHidden = true
s.translatesAutoresizingMaskIntoConstraints = false
return s
}()

private let errorLabel: NSTextField = {
let tf = NSTextField(labelWithString: "")
tf.font = NSFont(name: "Roboto-Regular", size: 11) ?? NSFont.systemFont(ofSize: 11)
tf.textColor = NSColor.systemRed
tf.lineBreakMode = .byWordWrapping
tf.maximumNumberOfLines = 2
tf.isHidden = true
tf.translatesAutoresizingMaskIntoConstraints = false
tf.isEditable = false
tf.isSelectable = false
return tf
}()

// MARK: - Init

init(proposal: AIAgentManager.PendingProposal) {
Expand All @@ -111,7 +143,6 @@ final class ProposalApprovalCardView: NSView {
// MARK: - Configuration

private func configure(with proposal: AIAgentManager.PendingProposal) {
// Badge
let (badgeText, badgeColor) = kindAttributes(for: proposal.kind)
badgeLabel.stringValue = " \(badgeText) "
badgeLabel.wantsLayer = true
Expand All @@ -126,28 +157,26 @@ final class ProposalApprovalCardView: NSView {
switch kind.lowercased() {
case "initiative": return ("Initiative", NSColor.systemPurple)
case "milestone": return ("Milestone", NSColor.systemOrange)
default: return ("Feature", NSColor.systemBlue)
default: return ("Feature", NSColor.Sphinx.PrimaryBlue)
}
}

// MARK: - Layout

private func setupLayout() {
wantsLayer = true
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
layer?.backgroundColor = NSColor.Sphinx.ReceivedMsgBG.cgColor
layer?.cornerRadius = 10
layer?.borderWidth = 1
layer?.borderColor = NSColor.separatorColor.cgColor
layer?.borderWidth = 0

// Shadow
// Subtle shadow for elevation without border
shadow = NSShadow()
shadow?.shadowColor = NSColor.black.withAlphaComponent(0.12)
shadow?.shadowColor = NSColor.black.withAlphaComponent(0.10)
shadow?.shadowOffset = NSSize(width: 0, height: -2)
shadow?.shadowBlurRadius = 6

[badgeLabel, titleLabel, descLabel, approveButton, rejectButton, stampLabel, dismissButton].forEach {
addSubview($0)
}
[badgeLabel, titleLabel, descLabel, approveButton, rejectButton,
stampLabel, dismissButton, spinner, errorLabel].forEach { addSubview($0) }

approveButton.target = self
approveButton.action = #selector(handleApprove)
Expand All @@ -163,13 +192,13 @@ final class ProposalApprovalCardView: NSView {
dismissButton.widthAnchor.constraint(equalToConstant: 20),
dismissButton.heightAnchor.constraint(equalToConstant: 20),

// Badge — top left
badgeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12),
// Badge — vertically centered in its row (alongside dismiss button row)
badgeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor),
badgeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
badgeLabel.heightAnchor.constraint(equalToConstant: 18),

// Title
titleLabel.topAnchor.constraint(equalTo: badgeLabel.bottomAnchor, constant: 6),
titleLabel.topAnchor.constraint(equalTo: dismissButton.bottomAnchor, constant: 6),
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),

Expand All @@ -178,19 +207,32 @@ final class ProposalApprovalCardView: NSView {
descLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
descLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),

// Approve / Reject buttons
approveButton.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 10),
// Error label (below desc, above buttons)
errorLabel.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 6),
errorLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
errorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),

// Approve / Reject buttons row
approveButton.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 8),
approveButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
approveButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
approveButton.heightAnchor.constraint(equalToConstant: 28),
approveButton.widthAnchor.constraint(equalToConstant: 80),

rejectButton.topAnchor.constraint(equalTo: approveButton.topAnchor),
rejectButton.leadingAnchor.constraint(equalTo: approveButton.trailingAnchor, constant: 8),
rejectButton.heightAnchor.constraint(equalToConstant: 28),
rejectButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
rejectButton.widthAnchor.constraint(equalToConstant: 70),

// Spinner — trailing the reject button
spinner.centerYAnchor.constraint(equalTo: approveButton.centerYAnchor),
spinner.leadingAnchor.constraint(equalTo: rejectButton.trailingAnchor, constant: 10),
spinner.widthAnchor.constraint(equalToConstant: 16),
spinner.heightAnchor.constraint(equalToConstant: 16),

// Stamp label (hidden by default, replaces buttons when actioned)
stampLabel.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 10),
stampLabel.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 8),
stampLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
stampLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
stampLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
Expand All @@ -201,30 +243,64 @@ final class ProposalApprovalCardView: NSView {
// MARK: - Actions

@objc private func handleApprove() {
guard !isActioned else { return }
guard !isActioned, !isLoading else { return }
showLoading()
onApprove?(proposalId)
}

@objc private func handleReject() {
guard !isActioned else { return }
guard !isActioned, !isLoading else { return }
showLoading()
onReject?(proposalId)
}

@objc private func handleDismiss() {
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.2
animator().alphaValue = 0
}, completionHandler: {
self.removeFromSuperview()
})
if let onDismiss = onDismiss {
// Let the host VC handle cleanup (inset restoration etc.)
onDismiss()
} else {
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.2
animator().alphaValue = 0
}, completionHandler: {
self.removeFromSuperview()
})
}
}

// MARK: - State Updates
// MARK: - State Machine

func showLoading() {
guard !isActioned, !isLoading else { return }
isLoading = true
approveButton.isEnabled = false
rejectButton.isEnabled = false
spinner.isHidden = false
spinner.startAnimation(nil)
errorLabel.isHidden = true
}

func showError(_ message: String) {
isLoading = false
spinner.stopAnimation(nil)
spinner.isHidden = true
approveButton.isEnabled = true
rejectButton.isEnabled = true
approveButton.isHidden = false
rejectButton.isHidden = false
stampLabel.isHidden = true
errorLabel.stringValue = message
errorLabel.isHidden = false
}

func showStamp(approved: Bool) {
isActioned = true
isLoading = false
spinner.stopAnimation(nil)
spinner.isHidden = true
approveButton.isHidden = true
rejectButton.isHidden = true
errorLabel.isHidden = true
stampLabel.isHidden = false
if approved {
stampLabel.stringValue = "✅ Approved"
Expand All @@ -233,24 +309,22 @@ final class ProposalApprovalCardView: NSView {
stampLabel.stringValue = "❌ Rejected"
stampLabel.textColor = NSColor.systemRed
}
// Auto-dismiss after 3 seconds
// Auto-dismiss after 3 seconds via host-VC closure if available
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.handleDismiss()
}
}

func showError(_ message: String) {
approveButton.isHidden = false
rejectButton.isHidden = false
stampLabel.stringValue = "⚠️ \(message)"
stampLabel.textColor = NSColor.systemRed
stampLabel.isHidden = false
}

func resetToActionable() {
isActioned = false
isLoading = false
spinner.stopAnimation(nil)
spinner.isHidden = true
approveButton.isEnabled = true
rejectButton.isEnabled = true
approveButton.isHidden = false
rejectButton.isHidden = false
stampLabel.isHidden = true
errorLabel.isHidden = true
}
}