feat(assistant): animate copy confirmation icons

This commit is contained in:
Kaede Akino committed 2026-10-05 06:45:38 +08:00
1 parent 0fae7c7fbe
commit 9666308c8e
5 files changed
+104 -9

No files matched your search

@@ -36,7 +36,7 @@ public struct AssistantCompactToolDetails: View {
expanded ? AppStrings.Assistant.showLess.swiftUIKey : AppStrings.Assistant.showMore.swiftUIKey
) { expanded.toggle() }
.accessibilityIdentifier("AssistantTool.More")
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { copy(preview.fullText) }
AssistantCopyButton(text: preview.fullText, copy: copy)
.accessibilityIdentifier("AssistantTool.Copy")
}
.font(.caption)
@@ -0,0 +1,47 @@
import SwiftUI
public struct AssistantCopyButton: View {
private let text: String
private let minimumLabelSize: CGFloat?
private let copy: (String) -> Void
@State private var copied = false
@State private var copyID = UUID()
public init(text: String, minimumLabelSize: CGFloat? = nil, copy: @escaping (String) -> Void) {
self.text = text
self.minimumLabelSize = minimumLabelSize
self.copy = copy
}
public var body: some View {
Button {
copy(text)
copied = true
copyID = UUID()
} label: {
Label {
Text(AppStrings.Common.copy)
} icon: {
// Keep the copy glyph's layout footprint when the narrower checkmark appears.
Image(systemName: "doc.on.doc")
.hidden()
.overlay {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.contentTransition(.symbolEffect(.replace))
}
}
.frame(minWidth: minimumLabelSize, minHeight: minimumLabelSize)
.contentShape(.rect)
}
.accessibilityValue(Text(copied ? AppStrings.Common.copied : AppStrings.Common.copy))
.task(id: copyID) {
guard copied else { return }
do { try await Task.sleep(for: .seconds(1.4)) } catch { return }
copied = false
}
.onChange(of: text) { _, _ in
copied = false
copyID = UUID()
}
}
}
@@ -70,7 +70,7 @@ public struct AssistantToolDetails: View {
HStack {
Text(AppStrings.Assistant.outputShortenedForDisplay).font(.caption2).foregroundStyle(.secondary)
if let copy {
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { copy(preview.full) }
AssistantCopyButton(text: preview.full, copy: copy)
.font(.caption).frame(minHeight: 44)
}
}
@@ -681,8 +681,8 @@ struct MobileAssistantConversation: View {
.foregroundStyle(.red)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
UIPasteboard.general.string = failure.reason
AssistantCopyButton(text: failure.reason) {
UIPasteboard.general.string = $0
}
.labelStyle(.iconOnly)
.buttonStyle(.plain)
@@ -809,9 +809,13 @@ struct MobileAssistantConversation: View {
}
}
messageAction(AppStrings.Common.copy, systemImage: "doc.on.doc") {
UIPasteboard.general.string = message.text
AssistantCopyButton(text: message.text, minimumLabelSize: 44) {
UIPasteboard.general.string = $0
}
.font(.caption)
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
if let origin = message.origin {
let presentation = MobileAssistantAgentOriginPresentation(
+47 -3
View File
@@ -2813,8 +2813,26 @@ private class NativeButton: NSButton {
@objc private func run() { handler?() }
}
@MainActor
private final class NativeCopyButtonCell: NSButtonCell {
// The image view draws the symbol so it can use the system replacement transition.
// Retain the cell's image and metrics for the existing button layout.
override func drawImage(_ image: NSImage, withFrame frame: NSRect, in controlView: NSView) {}
}
@MainActor
private final class NativeCopySymbolView: NSImageView {
override func hitTest(_ point: NSPoint) -> NSView? { nil }
}
@MainActor
private final class NativeCopyButton: NativeButton {
override class var cellClass: AnyClass? {
get { NativeCopyButtonCell.self }
set {}
}
private let symbolView = NativeCopySymbolView()
private let copiedImage = NSImage(
systemSymbolName: "checkmark",
accessibilityDescription: String(localized: AppStrings.Common.copied)
@@ -2831,16 +2849,28 @@ private final class NativeCopyButton: NativeButton {
image = copyImage
imagePosition = .imageOnly
toolTip = help
setAccessibilityLabel(help)
widthAnchor.constraint(equalToConstant: 28).isActive = true
heightAnchor.constraint(equalToConstant: 28).isActive = true
symbolView.image = copyImage
symbolView.imageScaling = .scaleNone
symbolView.symbolConfiguration = NSImage.SymbolConfiguration(
pointSize: NSFont.systemFontSize(for: controlSize),
weight: .regular
)
symbolView.setAccessibilityElement(false)
addSubview(symbolView)
handler = { [weak self] in
guard copyToPasteboard(text), let self else { return }
image = copiedImage
showSymbol(copiedImage)
toolTip = String(localized: AppStrings.Common.copied)
setAccessibilityValue(toolTip)
resetWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.image = self?.copyImage
self?.toolTip = String(localized: AppStrings.Common.copy)
guard let self else { return }
showSymbol(copyImage)
toolTip = String(localized: AppStrings.Common.copy)
setAccessibilityValue(nil)
}
resetWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
@@ -2848,4 +2878,18 @@ private final class NativeCopyButton: NativeButton {
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func layout() {
super.layout()
symbolView.frame = bounds
}
private func showSymbol(_ image: NSImage?) {
guard let image, symbolView.image !== image else { return }
if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
symbolView.image = image
} else {
symbolView.setSymbolImage(image, contentTransition: .replace)
}
}
}