From 9a778d81d554b43413bb1fad9dbc2e03973530bd Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 13 Sep 2026 09:55:58 +0800 Subject: [PATCH] TFI Hover skills & plan --- TFI/Features/AI/MobileAssistantComposer.swift | 44 +++++++++++++------ TFITests/MobileNavigationParityTests.swift | 40 +++++++++++++++-- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index bf083ed0..1a7110a4 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -26,6 +26,27 @@ extension View { } } +private struct MobileDismissibleComposerBadge: View { + let symbol: String + let help: Text + let accessibilityLabel: Text + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: isHovering ? "xmark" : symbol) + .contentTransition(.symbolEffect(.replace)) + .mobileComposerControlIcon(opacity: 0.62) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help(help) + .accessibilityLabel(accessibilityLabel) + } +} + struct MobileAssistantComposer: View { let pro: ProAccessController let model: MobileAssistantModel @@ -480,25 +501,22 @@ struct MobileAssistantComposer: View { @ViewBuilder private var badges: some View { if conversation.isPlanning { - Button { + MobileDismissibleComposerBadge( + symbol: "lightbulb.max", + help: Text("Plan mode — click to turn off"), + accessibilityLabel: Text("Plan mode on, turn off") + ) { conversation.isPlanning = false - } label: { - Label("Plan", systemImage: "lightbulb.max") - .labelStyle(.iconOnly) - .mobileComposerControlIcon(opacity: 0.62) } - .buttonStyle(.plain) } if let skill = composer.armedSkillName { - Button { + MobileDismissibleComposerBadge( + symbol: "book.closed", + help: Text("\(skill) will be loaded for this message — click to remove"), + accessibilityLabel: Text("Skill \(skill) armed, remove") + ) { composer.armedSkillName = nil - } label: { - Label(skill, systemImage: "book.closed") - .labelStyle(.iconOnly) - .mobileComposerControlIcon(opacity: 0.62) } - .buttonStyle(.plain) - .accessibilityLabel(Text("Skill \(skill) armed, remove")) } } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index fc55fb08..f4279f90 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -350,10 +350,40 @@ struct MobileNavigationParityTests { composer.components(separatedBy: "private var badges").last? .components(separatedBy: "private var modelProviders").first) #expect(badges.contains("armedSkillName")) - #expect(badges.components(separatedBy: ".labelStyle(.iconOnly)").count == 3) + #expect(badges.components(separatedBy: "MobileDismissibleComposerBadge(").count == 3) #expect(composer.contains(".symbolRenderingMode(.monochrome)")) } + @Test func assistantPlanAndSkillBadgesExposeDesktopHoverRemovalAffordance() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + + let badge = try #require( + composer.components(separatedBy: "private struct MobileDismissibleComposerBadge").last? + .components(separatedBy: "struct MobileAssistantComposer").first) + #expect(badge.contains("@State private var isHovering = false")) + #expect(badge.contains("isHovering ? \"xmark\" : symbol")) + #expect(badge.contains(".contentTransition(.symbolEffect(.replace))")) + #expect(badge.contains(".mobileComposerControlIcon(opacity: 0.62)")) + #expect(badge.contains(".onHover { isHovering = $0 }")) + #expect(badge.contains(".help(help)")) + #expect(badge.contains(".accessibilityLabel(accessibilityLabel)")) + + let badges = try #require( + composer.components(separatedBy: "@ViewBuilder private var badges").last? + .components(separatedBy: "private var modelProviders").first) + #expect(badges.contains("symbol: \"lightbulb.max\"")) + #expect(badges.contains("conversation.isPlanning = false")) + #expect(badges.contains("Text(\"Plan mode — click to turn off\")")) + #expect(badges.contains("Text(\"Plan mode on, turn off\")")) + #expect(badges.contains("symbol: \"book.closed\"")) + #expect(badges.contains("composer.armedSkillName = nil")) + #expect(badges.contains("Skill \\(skill) armed, remove")) + } + @Test func assistantPermissionMenuUsesLeadingCheckmarkAndKeepsItsTriggerVisible() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -439,7 +469,6 @@ struct MobileNavigationParityTests { ("private var addMenu", "private var permissionMenu"), ("private var permissionMenu", "private var resolvedPermissionMode"), ("private var microphoneButton", "@ViewBuilder private var primaryButton"), - ("@ViewBuilder private var badges", "private var modelProviders"), ] { let controls = try #require( composer.components(separatedBy: markers.0).last? @@ -450,7 +479,12 @@ struct MobileNavigationParityTests { let badges = try #require( composer.components(separatedBy: "@ViewBuilder private var badges").last? .components(separatedBy: "private var modelProviders").first) - #expect(badges.components(separatedBy: ".mobileComposerControlIcon(opacity: 0.62)").count == 3) + #expect(badges.components(separatedBy: "MobileDismissibleComposerBadge(").count == 3) + + let dismissibleBadge = try #require( + composer.components(separatedBy: "private struct MobileDismissibleComposerBadge").last? + .components(separatedBy: "struct MobileAssistantComposer").first) + #expect(dismissibleBadge.contains(".mobileComposerControlIcon(opacity: 0.62)")) let primaryButton = try #require( composer.components(separatedBy: "@ViewBuilder private var primaryButton").last?