mac: align web chat UI with web

This commit is contained in:
Peter Steinberger
2025-12-10 02:18:50 +01:00
parent 6c1638890c
commit d077936a21
2 changed files with 108 additions and 38 deletions

View File

@@ -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)

View File

@@ -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)
}