From 479fb7495ed5da3a5188f14ad00c2849dd43da61 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Mon, 18 May 2026 06:05:47 +0000 Subject: [PATCH] Fix panel new chat hover affordance --- docs/plans/panel-new-session-hover.md | 30 +++++ .../Helpers/View+SafeGlassEffect.swift | 23 ++++ .../Conversation/ConversationListMenu.swift | 16 ++- .../PanelViews/ChatWindowHeaderView.swift | 17 ++- .../Suites/MainWindowUITests.swift | 126 ++++++++++++++++++ .../Suites/OpenBridgeUnitTests.swift | 12 ++ 6 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 docs/plans/panel-new-session-hover.md diff --git a/docs/plans/panel-new-session-hover.md b/docs/plans/panel-new-session-hover.md new file mode 100644 index 0000000..f44596b --- /dev/null +++ b/docs/plans/panel-new-session-hover.md @@ -0,0 +1,30 @@ +# Panel New Session Hover Plan + +## Goal +Make the small-window chat header “New Chat” / new-session button show a hover treatment that visually matches the adjacent header buttons. + +## Current State +- The macOS 26 panel header uses `ConversationHistoryLiquidActionGroup` for the New Chat, History, and More capsule. +- The New Chat button owns its hover state separately and paints a low-contrast circle behind the plus icon. +- The legacy/fallback panel header `newChatButton` is only a borderless icon and has no custom hover background. +- Adjacent buttons use explicit circular glass/hover affordances, so the New Chat button can look like its hover is missing. + +## Proposed Changes +1. Add a shared chat-header icon hover style so panel header buttons use one hover opacity/diameter source. +2. Apply the shared hover background to the liquid capsule buttons and to the legacy/fallback New Chat button. +3. Add full circular hit testing to the New Chat label so hovering anywhere in the intended button area activates the style. +4. Keep all actions, keyboard shortcuts, accessibility identifiers, and presentation logic unchanged. + +## What Happens If We Do Not Change It +The New Chat button continues to look visually inconsistent in the small-window header and can appear to have no hover feedback. + +## Expected Result After Change +Hovering the small-window New Chat button shows the same type and strength of circular hover feedback as adjacent header controls, without changing click behavior or shortcuts. + +## Acceptance Criteria +- Panel New Chat hover has visible feedback comparable to adjacent header buttons. +- History, More, switch-presentation, and close behaviors remain unchanged. +- New Chat click and ⌘N still create a new chat. +- Accessibility identifiers remain stable. +- Unit/UI test coverage documents and protects the hover affordance. +- Build/test and manual smoke validation complete or blockers are explicitly reported. diff --git a/macos/OpenBridge/Helpers/View+SafeGlassEffect.swift b/macos/OpenBridge/Helpers/View+SafeGlassEffect.swift index 8e9c9d9..64792b4 100644 --- a/macos/OpenBridge/Helpers/View+SafeGlassEffect.swift +++ b/macos/OpenBridge/Helpers/View+SafeGlassEffect.swift @@ -69,6 +69,29 @@ enum ChatHeaderLiquidGlassStyle { } } +enum ChatHeaderIconHoverStyle { + static let hoverFillOpacity = 0.12 + static let compactHoverDiameter: CGFloat = 26 + static let standaloneHoverDiameter: CGFloat = 32 + + static func fillOpacity(isHovered: Bool) -> Double { + isHovered ? hoverFillOpacity : 0 + } +} + +struct ChatHeaderIconHoverBackground: View { + let isHovered: Bool + let diameter: CGFloat + + var body: some View { + Circle() + .fill(Color.primary.opacity(ChatHeaderIconHoverStyle.fillOpacity(isHovered: isHovered))) + .frame(width: diameter, height: diameter) + .animation(.easeInOut(duration: 0.12), value: isHovered) + .allowsHitTesting(false) + } +} + private struct ChatHeaderLiquidGlassModifier: ViewModifier { let shape: GlassShape diff --git a/macos/OpenBridge/Interface/Conversation/ConversationListMenu.swift b/macos/OpenBridge/Interface/Conversation/ConversationListMenu.swift index 82f91fb..3c778cf 100644 --- a/macos/OpenBridge/Interface/Conversation/ConversationListMenu.swift +++ b/macos/OpenBridge/Interface/Conversation/ConversationListMenu.swift @@ -394,7 +394,7 @@ struct ConversationHistoryLiquidActionGroup: View { private let buttonWidth: CGFloat = 22 private let buttonHeight: CGFloat = 32 private let buttonSpacing: CGFloat = 2 - private let hoverCircleDiameter: CGFloat = 22 + private let hoverCircleDiameter = ChatHeaderIconHoverStyle.compactHoverDiameter private let capsuleHorizontalPadding: CGFloat = 4 private let menuCornerRadius: CGFloat = 22 @@ -592,17 +592,20 @@ struct ConversationHistoryLiquidActionGroup: View { Image(systemName: "plus") .frame(width: iconSize, height: iconSize) .frame(width: buttonWidth, height: buttonHeight) + .contentShape(Circle()) .background { hoverBackground(isHovered: isNewChatHovered) } } .buttonStyle(.plain) .keyboardShortcut("n", modifiers: .command) .help("New Chat (⌘N)") + .accessibilityLabel("New Chat") .accessibilityIdentifier(AccessibilityID.Chat.newChatButton) .onHover { isNewChatHovered = $0 } Button(action: openHistoryMenu) { historyIcon .frame(width: buttonWidth, height: buttonHeight) + .contentShape(Circle()) .background { hoverBackground(isHovered: isHistoryHovered) } } .buttonStyle(.plain) @@ -615,6 +618,7 @@ struct ConversationHistoryLiquidActionGroup: View { Image(systemName: "ellipsis") .frame(width: iconSize, height: iconSize) .frame(width: buttonWidth, height: buttonHeight) + .contentShape(Circle()) .background { hoverBackground(isHovered: isMoreHovered) } } .buttonStyle(.plain) @@ -625,13 +629,11 @@ struct ConversationHistoryLiquidActionGroup: View { } } - @ViewBuilder private func hoverBackground(isHovered: Bool) -> some View { - if isHovered { - Circle() - .fill(Color.primary.opacity(0.07)) - .frame(width: hoverCircleDiameter, height: hoverCircleDiameter) - } + ChatHeaderIconHoverBackground( + isHovered: isHovered, + diameter: hoverCircleDiameter + ) } private var historyIcon: some View { diff --git a/macos/OpenBridge/Interface/PanelViews/ChatWindowHeaderView.swift b/macos/OpenBridge/Interface/PanelViews/ChatWindowHeaderView.swift index 25a9f7a..b267424 100644 --- a/macos/OpenBridge/Interface/PanelViews/ChatWindowHeaderView.swift +++ b/macos/OpenBridge/Interface/PanelViews/ChatWindowHeaderView.swift @@ -225,6 +225,7 @@ private final class HeaderDragNSView: NSView { private struct ChatWindowHeaderActions: View { @Environment(SettingsManager.self) private var settingsManager + @State private var isNewChatHovered = false let searchModel: ChatConversationSearchModel let currentConversationId: String? @@ -323,11 +324,25 @@ private struct ChatWindowHeaderActions: View { onNewChat() }) { Image(systemName: "plus") + .frame(width: 16, height: 16) + .frame( + width: ChatHeaderIconHoverStyle.standaloneHoverDiameter, + height: ChatHeaderIconHoverStyle.standaloneHoverDiameter + ) + .contentShape(Circle()) + .background { + ChatHeaderIconHoverBackground( + isHovered: isNewChatHovered, + diameter: ChatHeaderIconHoverStyle.standaloneHoverDiameter + ) + } } - .buttonStyle(.borderless) + .buttonStyle(.plain) .keyboardShortcut("n", modifiers: .command) .help("New Chat (⌘N)") + .accessibilityLabel("New Chat") .accessibilityIdentifier(AccessibilityID.Chat.newChatButton) + .onHover { isNewChatHovered = $0 } } } diff --git a/macos/OpenBridgeUITests/Suites/MainWindowUITests.swift b/macos/OpenBridgeUITests/Suites/MainWindowUITests.swift index 6fe211e..1c58de5 100644 --- a/macos/OpenBridgeUITests/Suites/MainWindowUITests.swift +++ b/macos/OpenBridgeUITests/Suites/MainWindowUITests.swift @@ -5,6 +5,7 @@ // Created by GPT-5 Codex on 06/12/2025. // +import AppKit import XCTest final class MainWindowUITests: XCTestCase { @@ -13,6 +14,7 @@ final class MainWindowUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() + app.launchArguments.append("-e2eMode") app.launch() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8)) } @@ -34,4 +36,128 @@ final class MainWindowUITests: XCTestCase { attachment.lifetime = .keepAlways add(attachment) } + + func testPanelNewChatButtonHoverShowsVisualFeedback() throws { + let window = waitForMainWindow() + let newChatButton = waitForHittableButton( + identifier: "chat.header.newChatButton", + fallbackNames: ["New Chat", "Add"] + ) + XCTAssertTrue(newChatButton.isHittable) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.9)).hover() + Thread.sleep(forTimeInterval: 0.2) + let unhovered = newChatButton.screenshot() + + newChatButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).hover() + Thread.sleep(forTimeInterval: 0.2) + let hovered = newChatButton.screenshot() + + let delta = try HoverScreenshotDelta.measure(before: unhovered, after: hovered) + XCTAssertGreaterThan(delta.changedPixelCount, 12) + XCTAssertGreaterThan(delta.averageChannelDelta, 0.2) + + attach(window: window, name: "Panel New Chat Hover") + } + + private func waitForHittableButton( + identifier: String, + fallbackNames: [String] = [], + timeout: TimeInterval = 8 + ) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + let identifierButtons = app.buttons.matching(identifier: identifier) + + repeat { + if let button = identifierButtons.allElementsBoundByIndex.first(where: { $0.exists && $0.isHittable }) { + return button + } + for name in fallbackNames { + let button = app.buttons[name].firstMatch + if button.exists, button.isHittable { + return button + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } while Date() < deadline + + let fallback = identifierButtons.firstMatch + XCTAssertTrue(fallback.exists, "Expected hittable button with identifier \(identifier)") + XCTAssertTrue(fallback.isHittable, "Expected hittable button with identifier \(identifier)") + return fallback + } +} + +private struct HoverScreenshotDelta { + let changedPixelCount: Int + let averageChannelDelta: Double + + static func measure( + before: XCUIScreenshot, + after: XCUIScreenshot + ) throws -> HoverScreenshotDelta { + let beforeImage = try bitmap(from: before) + let afterImage = try bitmap(from: after) + let sampleWidth = max(beforeImage.pixelsWide, afterImage.pixelsWide) + let sampleHeight = max(beforeImage.pixelsHigh, afterImage.pixelsHigh) + let beforeOffsetX = (sampleWidth - beforeImage.pixelsWide) / 2 + let beforeOffsetY = (sampleHeight - beforeImage.pixelsHigh) / 2 + let afterOffsetX = (sampleWidth - afterImage.pixelsWide) / 2 + let afterOffsetY = (sampleHeight - afterImage.pixelsHigh) / 2 + + var changedPixels = 0 + var totalDelta = 0.0 + var sampleCount = 0 + + for y in 0 ..< sampleHeight { + for x in 0 ..< sampleWidth { + let beforeColor = deviceRGBColor( + atX: x - beforeOffsetX, + y: y - beforeOffsetY, + in: beforeImage + ) + let afterColor = deviceRGBColor( + atX: x - afterOffsetX, + y: y - afterOffsetY, + in: afterImage + ) + let delta = channelDelta(beforeColor, afterColor) + totalDelta += delta + sampleCount += 1 + if delta > 1.0 { + changedPixels += 1 + } + } + } + + return HoverScreenshotDelta( + changedPixelCount: changedPixels, + averageChannelDelta: sampleCount == 0 ? 0 : totalDelta / Double(sampleCount) + ) + } + + private static func bitmap(from screenshot: XCUIScreenshot) throws -> NSBitmapImageRep { + try XCTUnwrap(NSBitmapImageRep(data: screenshot.pngRepresentation)) + } + + private static func deviceRGBColor(atX x: Int, y: Int, in image: NSBitmapImageRep) -> NSColor? { + guard x >= 0, y >= 0, x < image.pixelsWide, y < image.pixelsHigh else { + return nil + } + return image.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) + } + + private static func channelDelta(_ before: NSColor?, _ after: NSColor?) -> Double { + switch (before, after) { + case (.none, .none): + return 0 + case (.none, .some), (.some, .none): + return 255 + case let (.some(before), .some(after)): + let red = abs(before.redComponent - after.redComponent) + let green = abs(before.greenComponent - after.greenComponent) + let blue = abs(before.blueComponent - after.blueComponent) + return Double((red + green + blue) * 255 / 3) + } + } } diff --git a/macos/OpenBridgeUnitTests/Suites/OpenBridgeUnitTests.swift b/macos/OpenBridgeUnitTests/Suites/OpenBridgeUnitTests.swift index aa5652f..bbcf91d 100644 --- a/macos/OpenBridgeUnitTests/Suites/OpenBridgeUnitTests.swift +++ b/macos/OpenBridgeUnitTests/Suites/OpenBridgeUnitTests.swift @@ -5,6 +5,18 @@ // Created by qaq on 16/10/2025. // +@testable import OpenBridge import Testing struct OpenBridgeUnitTests {} + +struct ChatHeaderIconHoverStyleTests { + @Test + func `panel hover affordance uses visible shared dimensions`() { + #expect(ChatHeaderIconHoverStyle.compactHoverDiameter == 26) + #expect(ChatHeaderIconHoverStyle.standaloneHoverDiameter == 32) + #expect(ChatHeaderIconHoverStyle.hoverFillOpacity >= 0.12) + #expect(ChatHeaderIconHoverStyle.fillOpacity(isHovered: false) == 0) + #expect(ChatHeaderIconHoverStyle.fillOpacity(isHovered: true) == ChatHeaderIconHoverStyle.hoverFillOpacity) + } +}