Skip to content
Merged
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
30 changes: 30 additions & 0 deletions docs/plans/panel-new-session-hover.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions macos/OpenBridge/Helpers/View+SafeGlassEffect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlassShape: InsettableShape>: ViewModifier {
let shape: GlassShape

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion macos/OpenBridge/Interface/PanelViews/ChatWindowHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 }
}
}

Expand Down
126 changes: 126 additions & 0 deletions macos/OpenBridgeUITests/Suites/MainWindowUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by GPT-5 Codex on 06/12/2025.
//

import AppKit
import XCTest

final class MainWindowUITests: XCTestCase {
Expand All @@ -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))
}
Expand All @@ -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)
}
}
}
12 changes: 12 additions & 0 deletions macos/OpenBridgeUnitTests/Suites/OpenBridgeUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading