From 7e3cdf9e6b46eab38c813263a34548d90830f928 Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Thu, 25 Jun 2026 15:40:10 +0000 Subject: [PATCH 1/2] Generated with Hive: Restyle proposal card and add loading, error, and dismiss logic --- ...NewChatViewController+AgentExtension.swift | 41 ++++- .../NewChatViewController.swift | 2 + .../ProposalApprovalCardView.swift | 166 +++++++++++++----- 3 files changed, 155 insertions(+), 54 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift index 0d5b9032..85aa218f 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift @@ -55,34 +55,45 @@ extension NewChatViewController { } } + // Use onDismiss so manual ✕ tap goes through the VC and restores inset + card.onDismiss = { [weak self] in + self?.removeProposalCard() + } + proposalCard = card + // Set flag so the first insertAgentReply (the proposal-carrying reply) doesn't dismiss the card + skipNextAgentReplyDismiss = true + 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 } @@ -182,6 +193,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() @@ -239,6 +254,16 @@ extension NewChatViewController { func insertAgentReply(_ text: String) { hideAgentProcessingBar() + + // Dismiss proposal card on subsequent agent replies (skip the first one — it's the proposal-carrying reply) + if proposalCard != nil { + if skipNextAgentReplyDismiss { + skipNextAgentReplyDismiss = false + } else { + removeProposalCard() + } + } + guard let chat = self.chat, let owner = self.owner else { return } let incoming = TransactionMessage(context: CoreDataManager.sharedManager.persistentContainer.viewContext) incoming.id = SphinxOnionManager.sharedInstance.uniqueIntHashFromString(stringInput: UUID().uuidString) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift index ae474cf9..92f7a027 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift @@ -79,6 +79,8 @@ class NewChatViewController: DashboardSplittedViewController { var agentBarHeightConstraint: NSLayoutConstraint? var agentProcessingBarTimer: Timer? var proposalCard: ProposalApprovalCardView? + var proposalCardInset: CGFloat = 0 + var skipNextAgentReplyDismiss: Bool = false var contactResultsController: NSFetchedResultsController! var chatResultsController: NSFetchedResultsController! diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/ProposalApprovalCardView.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/ProposalApprovalCardView.swift index 0cff7661..4c2fbb9b 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/ProposalApprovalCardView.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/ProposalApprovalCardView.swift @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) { @@ -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 @@ -126,7 +157,7 @@ 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) } } @@ -134,20 +165,18 @@ final class ProposalApprovalCardView: NSView { 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) @@ -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), @@ -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), @@ -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" @@ -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 } } From 84d3d915c8251020dbfb1cdb29e0318f4e136848 Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Thu, 25 Jun 2026 17:53:39 +0000 Subject: [PATCH 2/2] --- Generated with Hive: Remove redundant dismiss tracking for proposal card and simplify card removal logic --- --- .../NewChatViewController+AgentExtension.swift | 13 ------------- .../NewChatViewController.swift | 1 - 2 files changed, 14 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift index 85aa218f..a3e7f974 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController+AgentExtension.swift @@ -62,9 +62,6 @@ extension NewChatViewController { proposalCard = card - // Set flag so the first insertAgentReply (the proposal-carrying reply) doesn't dismiss the card - skipNextAgentReplyDismiss = true - NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.2 card.animator().alphaValue = 1 @@ -254,16 +251,6 @@ extension NewChatViewController { func insertAgentReply(_ text: String) { hideAgentProcessingBar() - - // Dismiss proposal card on subsequent agent replies (skip the first one — it's the proposal-carrying reply) - if proposalCard != nil { - if skipNextAgentReplyDismiss { - skipNextAgentReplyDismiss = false - } else { - removeProposalCard() - } - } - guard let chat = self.chat, let owner = self.owner else { return } let incoming = TransactionMessage(context: CoreDataManager.sharedManager.persistentContainer.viewContext) incoming.id = SphinxOnionManager.sharedInstance.uniqueIntHashFromString(stringInput: UUID().uuidString) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift index 92f7a027..6fe2a1fe 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/New Chat View Controller/NewChatViewController.swift @@ -80,7 +80,6 @@ class NewChatViewController: DashboardSplittedViewController { var agentProcessingBarTimer: Timer? var proposalCard: ProposalApprovalCardView? var proposalCardInset: CGFloat = 0 - var skipNextAgentReplyDismiss: Bool = false var contactResultsController: NSFetchedResultsController! var chatResultsController: NSFetchedResultsController!