diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index e9fe9ec3b..400ee2df0 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -9,6 +9,12 @@ extension EventFrame: @unchecked Sendable {} private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI") +private enum WebChatSwiftUILayout { + static let windowSize = NSSize(width: 1120, height: 840) + static let panelSize = NSSize(width: 480, height: 640) + static let anchorPadding: CGFloat = 8 +} + // MARK: - Models struct GatewayChatMessageContent: Codable { @@ -271,32 +277,39 @@ struct WebChatView: View { @StateObject var viewModel: WebChatViewModel var body: some View { ZStack { - LinearGradient( - colors: [ - Color(red: 0.12, green: 0.17, blue: 0.28), - Color(red: 0.06, green: 0.07, blue: 0.11) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - .overlay(.ultraThinMaterial) + Color(nsColor: .windowBackgroundColor) .ignoresSafeArea() - VStack(spacing: 10) { + VStack(spacing: 14) { header messageList composer } - .padding(12) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .frame(maxWidth: 1040) } + .background( + LinearGradient( + colors: [ + Color(red: 0.96, green: 0.97, blue: 1.0), + Color(red: 0.93, green: 0.94, blue: 0.98) + ], + startPoint: .top, + endPoint: .bottom) + .opacity(0.35) + .ignoresSafeArea() + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { viewModel.load() } } private var header: some View { HStack { VStack(alignment: .leading, spacing: 2) { - Text("Clawdis Chat") - .font(.title3.weight(.semibold)) - Text("Session \(self.viewModel.thinkingLevel.uppercased()) · Gateway") + Text("Clawd Web Chat") + .font(.title2.weight(.semibold)) + Text("Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") .font(.caption) .foregroundStyle(.secondary) } @@ -309,8 +322,12 @@ struct WebChatView: View { .frame(width: 10, height: 10) } } - .padding(12) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 10, y: 4) + ) } private var messageList: some View { @@ -322,7 +339,11 @@ struct WebChatView: View { } .padding(.vertical, 8) } - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .shadow(color: .black.opacity(0.05), radius: 12, y: 6) + ) } private var composer: some View { @@ -354,21 +375,28 @@ struct WebChatView: View { } .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color.white.opacity(0.08)) + .padding(.horizontal, 10) + .background(Color.accentColor.opacity(0.08)) .clipShape(Capsule()) } } } } RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) + .strokeBorder(Color.secondary.opacity(0.2)) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) .overlay( TextEditor(text: self.$viewModel.input) + .font(.body) .background(Color.clear) - .frame(minHeight: 80, maxHeight: 140) - .padding(6) + .frame(minHeight: 96, maxHeight: 168) + .padding(.horizontal, 10) + .padding(.vertical, 8) ) - .frame(maxHeight: 160) + .frame(maxHeight: 180) HStack { if let error = self.viewModel.errorText { @@ -387,8 +415,12 @@ struct WebChatView: View { .disabled(self.viewModel.isSending) } } - .padding(12) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 12, y: 6) + ) .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) } @@ -401,8 +433,9 @@ struct WebChatView: View { Text("Medium").tag("medium") Text("High").tag("high") } - .pickerStyle(.segmented) - .frame(maxWidth: 260) + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) } private func pickFiles() { @@ -466,7 +499,11 @@ private struct MessageBubble: View { } .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) .padding(12) - .background(self.isUser ? Color.white.opacity(0.12) : Color.white.opacity(0.08)) + .background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15)) + ) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .padding(.horizontal, 6) @@ -537,9 +574,20 @@ final class WebChatSwiftUIWindowController { private func reposition(using anchorProvider: () -> NSRect?) { guard let window else { return } guard let anchor = anchorProvider() else { return } + let screen = NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + } ?? NSScreen.main var frame = window.frame - frame.origin.x = round(anchor.midX - frame.width / 2) - frame.origin.y = anchor.minY - frame.height + if let screen { + let minX = screen.frame.minX + WebChatSwiftUILayout.anchorPadding + let maxX = screen.frame.maxX - frame.width - WebChatSwiftUILayout.anchorPadding + frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX) + let desiredY = anchor.minY - frame.height - WebChatSwiftUILayout.anchorPadding + frame.origin.y = max(desiredY, screen.frame.minY + WebChatSwiftUILayout.anchorPadding) + } else { + frame.origin.x = round(anchor.midX - frame.width / 2) + frame.origin.y = anchor.minY - frame.height + } window.setFrame(frame, display: false) } @@ -567,21 +615,23 @@ final class WebChatSwiftUIWindowController { switch presentation { case .window: let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 960, height: 720), + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) window.title = "Clawdis Chat (SwiftUI)" window.contentViewController = contentViewController window.isReleasedWhenClosed = false - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.backgroundColor = .clear - window.isOpaque = false + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.backgroundColor = .windowBackgroundColor + window.isOpaque = true + window.center() + window.minSize = NSSize(width: 880, height: 680) return window case .panel: let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 440, height: 580), + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index baeb10081..98d37c461 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -6,6 +6,12 @@ import WebKit private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") +private enum WebChatLayout { + static let windowSize = NSSize(width: 1120, height: 840) + static let panelSize = NSSize(width: 480, height: 640) + static let anchorPadding: CGFloat = 8 +} + enum WebChatPresentation { case window case panel(anchorProvider: () -> NSRect?) @@ -75,16 +81,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N switch presentation { case .window: let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 960, height: 720), + contentRect: NSRect(origin: .zero, size: WebChatLayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) window.title = "Clawd Web Chat" window.contentView = wrappedContent + window.center() + window.minSize = NSSize(width: 880, height: 680) return window case .panel: let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 560), + contentRect: NSRect(origin: .zero, size: WebChatLayout.panelSize), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) @@ -312,8 +320,20 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N guard let anchor = anchorProvider() else { return } var frame = panel.frame - frame.origin.x = round(anchor.midX - frame.width / 2) - frame.origin.y = anchor.minY - frame.height + let screen = NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + } ?? NSScreen.main + + if let screen { + let minX = screen.frame.minX + WebChatLayout.anchorPadding + let maxX = screen.frame.maxX - frame.width - WebChatLayout.anchorPadding + frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX) + let desiredY = anchor.minY - frame.height - WebChatLayout.anchorPadding + frame.origin.y = max(desiredY, screen.frame.minY + WebChatLayout.anchorPadding) + } else { + frame.origin.x = round(anchor.midX - frame.width / 2) + frame.origin.y = anchor.minY - frame.height + } panel.setFrame(frame, display: false) }