Merge b1261d74d09a7836ad1c1715c873834d1714676e into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
dcad6c7ea2
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user