mac: align web chat UI with web
parent
6c1638890c
commit
d077936a21
|
|
@ -9,6 +9,12 @@ extension EventFrame: @unchecked Sendable {}
|
||||||
|
|
||||||
private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI")
|
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
|
// MARK: - Models
|
||||||
|
|
||||||
struct GatewayChatMessageContent: Codable {
|
struct GatewayChatMessageContent: Codable {
|
||||||
|
|
@ -271,32 +277,39 @@ struct WebChatView: View {
|
||||||
@StateObject var viewModel: WebChatViewModel
|
@StateObject var viewModel: WebChatViewModel
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
LinearGradient(
|
Color(nsColor: .windowBackgroundColor)
|
||||||
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)
|
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 14) {
|
||||||
header
|
header
|
||||||
messageList
|
messageList
|
||||||
composer
|
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() }
|
.onAppear { viewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Clawdis Chat")
|
Text("Clawd Web Chat")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title2.weight(.semibold))
|
||||||
Text("Session \(self.viewModel.thinkingLevel.uppercased()) · Gateway")
|
Text("Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -309,8 +322,12 @@ struct WebChatView: View {
|
||||||
.frame(width: 10, height: 10)
|
.frame(width: 10, height: 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(14)
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
.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 {
|
private var messageList: some View {
|
||||||
|
|
@ -322,7 +339,11 @@ struct WebChatView: View {
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.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 {
|
private var composer: some View {
|
||||||
|
|
@ -354,21 +375,28 @@ struct WebChatView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.white.opacity(0.08))
|
.padding(.horizontal, 10)
|
||||||
|
.background(Color.accentColor.opacity(0.08))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(.ultraThinMaterial)
|
.strokeBorder(Color.secondary.opacity(0.2))
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .textBackgroundColor))
|
||||||
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
TextEditor(text: self.$viewModel.input)
|
TextEditor(text: self.$viewModel.input)
|
||||||
|
.font(.body)
|
||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.frame(minHeight: 80, maxHeight: 140)
|
.frame(minHeight: 96, maxHeight: 168)
|
||||||
.padding(6)
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
)
|
)
|
||||||
.frame(maxHeight: 160)
|
.frame(maxHeight: 180)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let error = self.viewModel.errorText {
|
if let error = self.viewModel.errorText {
|
||||||
|
|
@ -387,8 +415,12 @@ struct WebChatView: View {
|
||||||
.disabled(self.viewModel.isSending)
|
.disabled(self.viewModel.isSending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(14)
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
.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
|
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||||
self.handleDrop(providers)
|
self.handleDrop(providers)
|
||||||
}
|
}
|
||||||
|
|
@ -401,8 +433,9 @@ struct WebChatView: View {
|
||||||
Text("Medium").tag("medium")
|
Text("Medium").tag("medium")
|
||||||
Text("High").tag("high")
|
Text("High").tag("high")
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
.frame(maxWidth: 260)
|
.pickerStyle(.menu)
|
||||||
|
.frame(maxWidth: 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickFiles() {
|
private func pickFiles() {
|
||||||
|
|
@ -466,7 +499,11 @@ private struct MessageBubble: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(12)
|
.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))
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
|
|
@ -537,9 +574,20 @@ final class WebChatSwiftUIWindowController {
|
||||||
private func reposition(using anchorProvider: () -> NSRect?) {
|
private func reposition(using anchorProvider: () -> NSRect?) {
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
guard let anchor = anchorProvider() 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
|
var frame = window.frame
|
||||||
frame.origin.x = round(anchor.midX - frame.width / 2)
|
if let screen {
|
||||||
frame.origin.y = anchor.minY - frame.height
|
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)
|
window.setFrame(frame, display: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -567,21 +615,23 @@ final class WebChatSwiftUIWindowController {
|
||||||
switch presentation {
|
switch presentation {
|
||||||
case .window:
|
case .window:
|
||||||
let window = NSWindow(
|
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],
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
window.title = "Clawdis Chat (SwiftUI)"
|
window.title = "Clawdis Chat (SwiftUI)"
|
||||||
window.contentViewController = contentViewController
|
window.contentViewController = contentViewController
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .visible
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = false
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .windowBackgroundColor
|
||||||
window.isOpaque = false
|
window.isOpaque = true
|
||||||
|
window.center()
|
||||||
|
window.minSize = NSSize(width: 880, height: 680)
|
||||||
return window
|
return window
|
||||||
case .panel:
|
case .panel:
|
||||||
let panel = NSPanel(
|
let panel = NSPanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 440, height: 580),
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
styleMask: [.nonactivatingPanel, .borderless],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import WebKit
|
||||||
|
|
||||||
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
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 {
|
enum WebChatPresentation {
|
||||||
case window
|
case window
|
||||||
case panel(anchorProvider: () -> NSRect?)
|
case panel(anchorProvider: () -> NSRect?)
|
||||||
|
|
@ -75,16 +81,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||||
switch presentation {
|
switch presentation {
|
||||||
case .window:
|
case .window:
|
||||||
let window = NSWindow(
|
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],
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
window.title = "Clawd Web Chat"
|
window.title = "Clawd Web Chat"
|
||||||
window.contentView = wrappedContent
|
window.contentView = wrappedContent
|
||||||
|
window.center()
|
||||||
|
window.minSize = NSSize(width: 880, height: 680)
|
||||||
return window
|
return window
|
||||||
case .panel:
|
case .panel:
|
||||||
let panel = NSPanel(
|
let panel = NSPanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 560),
|
contentRect: NSRect(origin: .zero, size: WebChatLayout.panelSize),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
styleMask: [.nonactivatingPanel, .borderless],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
|
|
@ -312,8 +320,20 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||||
guard let anchor = anchorProvider() else { return }
|
guard let anchor = anchorProvider() else { return }
|
||||||
|
|
||||||
var frame = panel.frame
|
var frame = panel.frame
|
||||||
frame.origin.x = round(anchor.midX - frame.width / 2)
|
let screen = NSScreen.screens.first { screen in
|
||||||
frame.origin.y = anchor.minY - frame.height
|
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)
|
panel.setFrame(frame, display: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue