Merge b1261d74d09a7836ad1c1715c873834d1714676e into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
706i 2026-03-21 03:15:10 +00:00 committed by GitHub
commit dcad6c7ea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 267 additions and 17 deletions

View File

@ -338,7 +338,12 @@ final class AppState {
if !self.isPreview {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
// Defer TalkModeController init to next run loop iteration to prevent
// re-entrant access to AppStateStore.shared during init (#36983).
let savedTalkEnabled = self.talkEnabled
DispatchQueue.main.async {
Task { await TalkModeController.shared.setEnabled(savedTalkEnabled) }
}
}
self.isInitializing = false

View File

@ -67,6 +67,13 @@ struct GeneralSettings: View {
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
binding: self.$state.peekabooBridgeEnabled)
if voiceWakeSupported {
SettingsToggleRow(
title: "Talk Mode",
subtitle: "Enable hands-free voice conversation with your assistant.",
binding: self.talkBinding)
}
SettingsToggleRow(
title: "Enable debug tools",
subtitle: "Show the Debug tab with development utilities.",
@ -101,6 +108,14 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 })
}
private var talkBinding: Binding<Bool> {
Binding(
get: { self.state.talkEnabled },
set: { newValue in
Task { await self.state.setTalkEnabled(newValue) }
})
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("OpenClaw runs")

View File

@ -154,8 +154,13 @@ struct OpenClawApp: App {
handler.onRightClick = { [self] in
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
WebChatManager.shared.closePanel()
self.isMenuPresented = true
self.updateStatusHighlight()
WebChatManager.shared.closeWindow()
// Deactivate the app briefly so MenuBarExtra menu can appear
// (SwiftUI menu won't show while an NSWindow is key)
DispatchQueue.main.async {
self.isMenuPresented = true
self.updateStatusHighlight()
}
}
handler.onHoverChanged = { [self] inside in
HoverHUDController.shared.statusItemHoverChanged(
@ -178,9 +183,7 @@ struct OpenClawApp: App {
self.isMenuPresented = false
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.togglePanel(
sessionKey: sessionKey,
anchorProvider: { [self] in self.statusButtonScreenFrame() })
WebChatManager.shared.toggleWindow(sessionKey: sessionKey)
}
}

View File

@ -59,6 +59,21 @@ final class WebChatManager {
controller.show()
}
/// Toggle the chat window (show/hide). Used by menu bar left-click.
func toggleWindow(sessionKey: String) {
self.closePanel()
if let controller = self.windowController, self.windowSessionKey == sessionKey {
if controller.isVisible {
controller.close()
self.onPanelVisibilityChanged?(false)
return
}
controller.show()
return
}
self.show(sessionKey: sessionKey)
}
func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) {
if let controller = self.panelController {
if self.panelSessionKey != sessionKey {
@ -93,6 +108,11 @@ final class WebChatManager {
self.panelController?.close()
}
func closeWindow() {
self.windowController?.close()
self.onPanelVisibilityChanged?(false)
}
func preferredSessionKey() async -> String {
if let cachedPreferredSessionKey { return cachedPreferredSessionKey }
let key = await GatewayConnection.shared.mainSessionKey()

View File

@ -1,5 +1,6 @@
import AppKit
import Foundation
import ObjectiveC
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
@ -362,6 +363,21 @@ final class WebChatSwiftUIWindowController {
window.minSize = WebChatSwiftUILayout.windowMinSize
window.contentView?.wantsLayer = true
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
// Add toolbar with gear menu
let toolbar = NSToolbar(identifier: "OpenClawChatToolbar")
toolbar.displayMode = .iconOnly
if #available(macOS 26.0, *) {
window.toolbarStyle = .unifiedCompact
} else {
window.toolbarStyle = .unified
}
let toolbarDelegate = ChatWindowToolbarDelegate()
toolbar.delegate = toolbarDelegate
window.toolbar = toolbar
// Retain the delegate (toolbar doesn't retain its delegate)
objc_setAssociatedObject(window, &chatToolbarDelegateKey, toolbarDelegate, .OBJC_ASSOCIATION_RETAIN)
return window
case .panel:
let panel = WebChatPanel(
@ -456,3 +472,121 @@ final class WebChatSwiftUIWindowController {
ColorHexSupport.color(fromHex: raw)
}
}
// MARK: - Chat window toolbar
nonisolated(unsafe) private var chatToolbarDelegateKey: UInt8 = 0
private extension NSToolbarItem.Identifier {
static let chatGearMenu = NSToolbarItem.Identifier("chatGearMenu")
}
@MainActor
final class ChatWindowToolbarDelegate: NSObject, NSToolbarDelegate {
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem?
{
guard itemIdentifier == .chatGearMenu else { return nil }
let item = NSMenuToolbarItem(itemIdentifier: itemIdentifier)
let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
item.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")?
.withSymbolConfiguration(config)
item.label = "Settings"
item.toolTip = "App settings and Talk Mode"
item.menu = self.buildGearMenu()
item.showsIndicator = true
return item
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.flexibleSpace, .chatGearMenu]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.flexibleSpace, .chatGearMenu]
}
private func buildGearMenu() -> NSMenu {
let menu = NSMenu()
// Talk Mode toggle
let talkItem = NSMenuItem(
title: AppStateStore.shared.talkEnabled ? "Stop Talk Mode" : "Talk Mode",
action: #selector(toggleTalkMode),
keyEquivalent: "")
talkItem.target = self
talkItem.state = AppStateStore.shared.talkEnabled ? .on : .off
talkItem.image = NSImage(systemSymbolName: "waveform.circle.fill", accessibilityDescription: nil)
if !voiceWakeSupported {
talkItem.isEnabled = false
}
menu.addItem(talkItem)
menu.addItem(.separator())
// Settings
let settingsItem = NSMenuItem(
title: "Settings…",
action: #selector(openSettings),
keyEquivalent: ",")
settingsItem.keyEquivalentModifierMask = .command
settingsItem.target = self
settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil)
menu.addItem(settingsItem)
menu.addItem(.separator())
// Quit
let quitItem = NSMenuItem(
title: "Quit OpenClaw",
action: #selector(quitApp),
keyEquivalent: "q")
quitItem.keyEquivalentModifierMask = .command
quitItem.target = self
menu.addItem(quitItem)
// Refresh menu items each time it opens
menu.delegate = self
return menu
}
@objc private func toggleTalkMode() {
Task { await AppStateStore.shared.setTalkEnabled(!AppStateStore.shared.talkEnabled) }
}
@objc private func toggleCanvas() {
Task {
if AppStateStore.shared.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
}
@objc private func openSettings() {
SettingsWindowOpener.shared.open()
}
@objc private func quitApp() {
NSApp.terminate(nil)
}
}
extension ChatWindowToolbarDelegate: NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
// Update Talk Mode item state
if let talkItem = menu.items.first(where: { $0.action == #selector(toggleTalkMode) }) {
let enabled = AppStateStore.shared.talkEnabled
talkItem.title = enabled ? "Stop Talk Mode" : "Talk Mode"
talkItem.state = enabled ? .on : .off
}
}
}

View File

@ -280,6 +280,9 @@ struct OpenClawChatComposer: View {
},
onPasteImageAttachment: { data, fileName, mimeType in
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
},
onDropFileURLs: { urls in
self.viewModel.addAttachments(urls: urls)
})
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
@ -373,7 +376,11 @@ struct OpenClawChatComposer: View {
}
private var textMaxHeight: CGFloat {
#if os(macOS)
self.style == .onboarding ? 52 : .infinity
#else
self.style == .onboarding ? 52 : 64
#endif
}
private var isComposerCompacted: Bool {
@ -440,6 +447,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
@Binding var shouldFocus: Bool
var onSend: () -> Void
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
var onDropFileURLs: (([URL]) -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(self) }
@ -472,6 +480,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
self.onSend()
}
textView.onPasteImageAttachment = self.onPasteImageAttachment
textView.onDropFileURLs = self.onDropFileURLs
let scroll = NSScrollView()
scroll.drawsBackground = false
@ -487,6 +496,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
textView.onPasteImageAttachment = self.onPasteImageAttachment
textView.onDropFileURLs = self.onDropFileURLs
if self.shouldFocus, let window = scrollView.window {
window.makeFirstResponder(textView)
@ -525,6 +535,19 @@ private struct ChatComposerTextView: NSViewRepresentable {
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?
/// Callback to route dropped file URLs to the attachment handler instead of inserting as text.
var onDropFileURLs: (([URL]) -> Void)?
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
let pboard = sender.draggingPasteboard
if let urls = pboard.readObjects(forClasses: [NSURL.self], options: [
.urlReadingFileURLsOnly: true
]) as? [URL], !urls.isEmpty, let handler = self.onDropFileURLs {
handler(urls)
return true
}
return super.performDragOperation(sender)
}
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
var types = super.readablePasteboardTypes

View File

@ -17,6 +17,8 @@ public struct OpenClawChatView: View {
@State private var hasPerformedInitialScroll = false
@State private var isPinnedToBottom = true
@State private var lastUserMessageID: UUID?
@State private var composerHeight: CGFloat = 150
@State private var composerDragStart: CGFloat?
private let showsSessionSwitcher: Bool
private let style: Style
private let markdownVariant: ChatMarkdownVariant
@ -68,18 +70,66 @@ public struct OpenClawChatView: View {
.ignoresSafeArea()
}
VStack(spacing: Layout.stackSpacing) {
self.messageList
.padding(.horizontal, Layout.outerPaddingHorizontal)
OpenClawChatComposer(
viewModel: self.viewModel,
style: self.style,
showsSessionSwitcher: self.showsSessionSwitcher)
.padding(.horizontal, Layout.composerPaddingHorizontal)
GeometryReader { geo in
let handleH: CGFloat = 15
let padV = Layout.outerPaddingVertical * 2
let listH = max(100, geo.size.height - padV - handleH - self.composerHeight)
VStack(spacing: 0) {
self.messageList
.padding(.horizontal, Layout.outerPaddingHorizontal)
.frame(height: listH)
.clipped()
// Resize handle
VStack(spacing: 0) {
Rectangle()
.fill(Color.primary.opacity(0.15))
.frame(height: 1)
HStack(spacing: 3) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 1)
.fill(Color.primary.opacity(0.3))
.frame(width: 20, height: 3)
}
}
.frame(maxWidth: .infinity)
.frame(height: 14)
}
.frame(height: handleH)
.contentShape(Rectangle())
#if os(macOS)
.onHover { inside in
if inside { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
#endif
.highPriorityGesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if self.composerDragStart == nil {
self.composerDragStart = self.composerHeight
}
let minH: CGFloat = 80
let maxH: CGFloat = geo.size.height - padV - handleH - 100
let proposed = (self.composerDragStart ?? 150) - value.translation.height
self.composerHeight = min(maxH, max(minH, proposed))
}
.onEnded { _ in
self.composerDragStart = nil
}
)
// Composer
OpenClawChatComposer(
viewModel: self.viewModel,
style: self.style,
showsSessionSwitcher: self.showsSessionSwitcher)
.padding(.horizontal, Layout.composerPaddingHorizontal)
.frame(height: self.composerHeight)
}
.padding(.vertical, Layout.outerPaddingVertical)
}
.padding(.vertical, Layout.outerPaddingVertical)
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity, alignment: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }