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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## [1.6.40-build-91] - [Unreleased]
## [1.6.40-build-92] - [Unreleased]

### Added

- Ten new themed app icons for Halloween and Christmas on iOS and iPadOS

### Changed

- macOS menu bar chat now appears only with a valid saved server configuration and uses a native brain SF Symbol
- New Chat and Private Chat shortcuts now consistently use Command-N and Shift-Command-N without conflicting with standard macOS actions

### Fixed

- Open in App from the macOS menu bar now persists and opens the current conversation, recreating or restoring the main window when needed
- Menu bar chat handoff now prevents unsent drafts, pending attachments, recordings, and empty streaming placeholders from being lost

## [1.6.35-build-90] - 2026-08-28

### Added
Expand Down
1 change: 1 addition & 0 deletions TestFlight/WhatToTest.en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Hi there! We've got some great new features for you in this update.
***1.6.40:

• 10 new themed app icons for Halloween and Christmas.
• On macOS, the improved menu bar chat now appears only after server setup and opens your current conversation directly in the main app window.
• Minor bug fixes and improvements for a smoother experience.

***Recent Updates:
Expand Down
4 changes: 4 additions & 0 deletions openclient-llm-macOS/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
ShortcutManager.shared.pendingAction = .newChat
}
}

func setOpenMainWindowAction(_ action: @escaping @MainActor () -> Void) {
menuBarManager.setOpenMainWindowAction(action)
}
}
31 changes: 24 additions & 7 deletions openclient-llm-macOS/App/OpenClientApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,34 @@ struct OpenClientApp: App {
// MARK: - View

var body: some Scene {
WindowGroup(id: "main") {
LaunchView()
.frame(minWidth: 800, minHeight: 600)
.onOpenURL { url in
guard let action = URLSchemeParser.parse(url) else { return }
URLSchemeManager.shared.pendingAction = action
}
Window(String(localized: "OpenClient"), id: "main") {
MainWindowContent(appDelegate: appDelegate)
}
.defaultSize(width: 800, height: 600)
.commands {
AppCommands()
}
}
}

// MARK: - MainWindowContent

private struct MainWindowContent: View {
@Environment(\.openWindow) private var openWindow

let appDelegate: AppDelegate

var body: some View {
LaunchView()
.frame(minWidth: 800, minHeight: 600)
.onAppear {
appDelegate.setOpenMainWindowAction {
openWindow(id: "main")
}
}
.onOpenURL { url in
guard let action = URLSchemeParser.parse(url) else { return }
URLSchemeManager.shared.pendingAction = action
}
}
}
4 changes: 2 additions & 2 deletions openclient-llm-macOS/Views/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ struct AppCommands: Commands {
Button(String(localized: "New Chat")) {
newChatAction?()
}
.keyboardShortcut("c", modifiers: .command)
.keyboardShortcut("n", modifiers: .command)
.disabled(newChatAction == nil)

Button(String(localized: "New Private Chat")) {
newPrivateChatAction?()
}
.keyboardShortcut("p", modifiers: [.command])
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(newPrivateChatAction == nil)

Divider()
Expand Down
49 changes: 43 additions & 6 deletions openclient-llm-macOS/Views/MenuBarChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import SwiftUI
struct MenuBarChatView: View {
// MARK: - Properties

var onOpenInApp: () -> Void
var onOpenInApp: (Conversation?) -> Void
var onAuthorizationStateChanged: (Bool) -> Void = { _ in }

@State private var chatId = UUID()
@State private var viewModel = ChatViewModel()
@State private var isOpeningInApp = false

// MARK: - View

Expand All @@ -31,6 +32,7 @@ struct MenuBarChatView: View {
viewModel: viewModel
)
.id(chatId)
.allowsHitTesting(!isOpeningInApp)
}
.frame(width: 380, height: 540)
.mcpToolAuthorizationPresentation(viewModel: viewModel, compact: true)
Expand All @@ -51,14 +53,26 @@ private extension MenuBarChatView {
.font(.headline)
Spacer()
Button {
onOpenInApp()
openInApp()
} label: {
Label(String(localized: "Open in App"), systemImage: "arrow.up.forward.app")
.font(.caption)
.labelStyle(.titleAndIcon)
HStack(spacing: 4) {
if isOpeningInApp {
ProgressView()
.controlSize(.small)
}
Label(String(localized: "Open in App"), systemImage: "arrow.up.forward.app")
.font(.caption)
.labelStyle(.titleAndIcon)
}
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(isOpeningInApp || !viewModel.canPrepareForAppHandoff)
.help(
!viewModel.canPrepareForAppHandoff
? String(localized: "Finish or clear the current input before opening it in the app.")
: String(localized: "Open in App")
)
Button {
viewModel.send(.stopStreamingTapped)
viewModel = ChatViewModel()
Expand All @@ -69,16 +83,39 @@ private extension MenuBarChatView {
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(isOpeningInApp)
.help(String(localized: "New Chat"))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}

func openInApp() {
guard !isOpeningInApp, viewModel.canPrepareForAppHandoff else { return }
isOpeningInApp = true
Task {
switch await viewModel.prepareForAppHandoff() {
case .newChat:
completeAppHandoff(conversation: nil)
case .conversation(let conversation):
completeAppHandoff(conversation: conversation)
case .draftPending, .persistenceFailed:
isOpeningInApp = false
}
}
}

func completeAppHandoff(conversation: Conversation?) {
onOpenInApp(conversation)
viewModel = ChatViewModel()
chatId = UUID()
isOpeningInApp = false
}
}

// MARK: - Preview

#Preview {
MenuBarChatView(onOpenInApp: {})
MenuBarChatView(onOpenInApp: { _ in })
.frame(width: 380, height: 540)
}
73 changes: 65 additions & 8 deletions openclient-llm-macOS/Views/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,55 @@ final class MenuBarManager: NSObject {

private var statusItem: NSStatusItem?
private var popover: NSPopover?
private var serverConfigurationObserver: NSObjectProtocol?
private var isAuthorizationPending = false
private let settingsManager: SettingsManagerProtocol
private var openMainWindow: (@MainActor () -> Void)?

// MARK: - Init

init(settingsManager: SettingsManagerProtocol = SettingsManager()) {
self.settingsManager = settingsManager
super.init()
}

// MARK: - Public

func setUp() {
guard serverConfigurationObserver == nil else { return }
serverConfigurationObserver = NotificationCenter.default.addObserver(
forName: .serverConfigurationDidChange,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.updateAvailability()
}
}
updateAvailability()
}

func setOpenMainWindowAction(_ action: @escaping @MainActor () -> Void) {
openMainWindow = action
}

// MARK: - Private

private func updateAvailability() {
if settingsManager.hasValidServerConfiguration() {
setUpStatusItem()
} else {
tearDownStatusItem()
}
}

private func setUpStatusItem() {
guard statusItem == nil else { return }

let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = item.button {
button.image = NSImage(
systemSymbolName: "message.circle.fill",
systemSymbolName: "brain.head.profile",
accessibilityDescription: String(localized: "OpenClient")
)
button.action = #selector(togglePopover(_:))
Expand All @@ -39,12 +77,8 @@ final class MenuBarManager: NSObject {
pop.behavior = .transient
pop.contentViewController = NSHostingController(
rootView: MenuBarChatView(
onOpenInApp: { [weak self] in
self?.popover?.performClose(nil)
NSApplication.shared.activate()
NSApplication.shared.windows
.first { !($0 is NSPanel) }?
.makeKeyAndOrderFront(nil)
onOpenInApp: { [weak self] conversation in
self?.openInApp(conversation: conversation)
},
onAuthorizationStateChanged: { [weak self, weak pop] isPending in
self?.isAuthorizationPending = isPending
Expand All @@ -57,7 +91,30 @@ final class MenuBarManager: NSObject {
popover = pop
}

// MARK: - Private
private func tearDownStatusItem() {
popover?.close()
popover = nil
isAuthorizationPending = false
guard let statusItem else { return }
NSStatusBar.system.removeStatusItem(statusItem)
self.statusItem = nil
}

private func openInApp(conversation: Conversation?) {
URLSchemeManager.shared.pendingResolvedConversation = conversation
URLSchemeManager.shared.pendingAction = conversation.map { .conversation(id: $0.id) } ?? .newChat
popover?.performClose(nil)

NSApplication.shared.activate()
if let mainWindow = NSApplication.shared.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) {
if mainWindow.isMiniaturized {
mainWindow.deminiaturize(nil)
}
mainWindow.makeKeyAndOrderFront(nil)
} else {
openMainWindow?()
}
}

@objc private func togglePopover(_ sender: AnyObject?) {
guard let popover, let button = statusItem?.button else { return }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// SettingsManagerServerConfigurationTests.swift
// openclient-llm-test
//
// Created by Arturo Carretero Calvo on 29/08/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import XCTest
@testable import openclient_llm

@MainActor
final class SettingsManagerServerConfigurationTests: XCTestCase {
// MARK: - Tests

func test_hasValidServerConfiguration_emptyURL_returnsFalse() {
// Given
let sut = MockSettingsManager()

// When
let isValid = sut.hasValidServerConfiguration()

// Then
XCTAssertFalse(isValid)
}

func test_hasValidServerConfiguration_relativeURL_returnsFalse() {
// Given
let sut = MockSettingsManager()
sut.serverBaseURL = "localhost:4000"

// When
let isValid = sut.hasValidServerConfiguration()

// Then
XCTAssertFalse(isValid)
}

func test_hasValidServerConfiguration_unsupportedScheme_returnsFalse() {
// Given
let sut = MockSettingsManager()
sut.serverBaseURL = "ftp://example.com"

// When
let isValid = sut.hasValidServerConfiguration()

// Then
XCTAssertFalse(isValid)
}

func test_hasValidServerConfiguration_HTTPServer_returnsTrue() {
// Given
let sut = MockSettingsManager()
sut.serverBaseURL = "http://localhost:4000/v1"

// When
let isValid = sut.hasValidServerConfiguration()

// Then
XCTAssertTrue(isValid)
}

func test_hasValidServerConfiguration_HTTPSServer_returnsTrue() {
// Given
let sut = MockSettingsManager()
sut.serverBaseURL = " https://example.com/v1 "

// When
let isValid = sut.hasValidServerConfiguration()

// Then
XCTAssertTrue(isValid)
}
}
Loading