mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:04:46 +00:00
style(mac): refine settings panes
This commit is contained in:
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/models: accept `thinkingFormat: "together"` in model compat config so Together routes can opt into the Together-specific thinking response shape.
|
||||
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.
|
||||
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
|
||||
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
|
||||
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
|
||||
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
|
||||
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
|
||||
|
||||
@@ -27,10 +27,6 @@ struct GeneralSettings: View {
|
||||
ProcessInfo.processInfo.isNixMode
|
||||
}
|
||||
|
||||
private var remoteLabelWidth: CGFloat {
|
||||
88
|
||||
}
|
||||
|
||||
init(state: AppState, page: Page = .general, isActive: Bool = true) {
|
||||
self.state = state
|
||||
self.page = page
|
||||
@@ -47,7 +43,7 @@ struct GeneralSettings: View {
|
||||
self.connectionPage
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 620, alignment: .leading)
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onAppear {
|
||||
@@ -65,72 +61,139 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var generalPage: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "General",
|
||||
subtitle: "Everyday OpenClaw app behavior.")
|
||||
|
||||
SettingsSection("App") {
|
||||
SettingsToggleRow(
|
||||
title: "OpenClaw active",
|
||||
subtitle: "Pause to stop the OpenClaw gateway; no messages will be processed.",
|
||||
binding: self.activeBinding)
|
||||
self.openClawStatusPanel
|
||||
|
||||
SettingsToggleRow(
|
||||
SettingsCardGroup("App") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Launch at login",
|
||||
subtitle: "Automatically start OpenClaw after you sign in.",
|
||||
binding: self.$state.launchAtLogin)
|
||||
|
||||
SettingsToggleRow(
|
||||
SettingsCardToggleRow(
|
||||
title: "Show Dock icon",
|
||||
subtitle: "Keep OpenClaw visible in the Dock. When off, windows still show the Dock icon while open.",
|
||||
binding: self.$state.showDockIcon)
|
||||
|
||||
SettingsToggleRow(
|
||||
SettingsCardToggleRow(
|
||||
title: "Play menu bar icon animations",
|
||||
subtitle: "Enable idle blinks and wiggles on the status icon.",
|
||||
binding: self.$state.iconAnimationsEnabled)
|
||||
binding: self.$state.iconAnimationsEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsSection("Capabilities") {
|
||||
SettingsToggleRow(
|
||||
SettingsCardGroup("Capabilities") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Allow Canvas",
|
||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||
binding: self.$state.canvasEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
SettingsCardToggleRow(
|
||||
title: "Allow Camera",
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
binding: self.$state.peekabooBridgeEnabled)
|
||||
binding: self.$state.peekabooBridgeEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsSection("Developer") {
|
||||
SettingsToggleRow(
|
||||
SettingsCardGroup("Developer") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable debug tools",
|
||||
subtitle: "Show the Debug page with development utilities.",
|
||||
binding: self.$state.debugPaneEnabled)
|
||||
binding: self.$state.debugPaneEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Quit OpenClaw") { NSApp.terminate(nil) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("App session")
|
||||
.font(.callout.weight(.medium))
|
||||
Text("Quit only when you want to stop the menu bar app completely.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 18)
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var openClawStatusPanel: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.state.isPaused ? Color.orange.opacity(0.18) : Color.green.opacity(0.18))
|
||||
Image(systemName: self.state.isPaused ? "pause.fill" : "checkmark")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(self.state.isPaused ? .orange : .green)
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.state.isPaused ? "OpenClaw paused" : "OpenClaw active")
|
||||
.font(.headline)
|
||||
Text(self.generalStatusSubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
Toggle("OpenClaw active", isOn: self.activeBinding)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
private var generalStatusSubtitle: String {
|
||||
if self.state.isPaused {
|
||||
return "Gateway work is paused; incoming messages will wait."
|
||||
}
|
||||
switch self.state.connectionMode {
|
||||
case .local:
|
||||
return "Processing messages through the local Gateway on this Mac."
|
||||
case .remote:
|
||||
return "Connected to a remote Gateway configuration."
|
||||
case .unconfigured:
|
||||
return "Ready to run after you choose a Gateway connection."
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPage: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "Connection",
|
||||
subtitle: "Choose where the Gateway runs and how this Mac app reaches it.")
|
||||
SettingsSection("Gateway") {
|
||||
self.connectionSection
|
||||
|
||||
self.connectionStatusPanel
|
||||
self.gatewayModeGroup
|
||||
|
||||
switch self.state.connectionMode {
|
||||
case .unconfigured:
|
||||
EmptyView()
|
||||
case .local:
|
||||
self.localGatewayGroup
|
||||
case .remote:
|
||||
self.remoteCard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,56 +216,163 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("OpenClaw runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
private var connectionStatusPanel: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(self.connectionStatusTint.opacity(0.18))
|
||||
Image(systemName: self.connectionStatusIcon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(self.connectionStatusTint)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .leading)
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.connectionStatusTitle)
|
||||
.font(.headline)
|
||||
Text(self.connectionStatusSubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
// In Nix mode, gateway is managed declaratively - no install buttons.
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
Spacer(minLength: 18)
|
||||
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
Text("\(Int(ping)) ms")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.green.opacity(0.16), in: Capsule())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusIcon: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local: "desktopcomputer"
|
||||
case .remote: self.state.remoteTransport == .ssh ? "point.3.connected.trianglepath.dotted" : "network"
|
||||
case .unconfigured: "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusTint: Color {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: .green
|
||||
case .connecting, .disconnected, .degraded: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusTitle: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local: "Local Gateway"
|
||||
case .remote: self.state.remoteTransport == .ssh ? "Remote Gateway via SSH" : "Remote Gateway direct"
|
||||
case .unconfigured: "Gateway not configured"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusSubtitle: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local:
|
||||
return "OpenClaw starts and monitors the Gateway on this Mac."
|
||||
case .remote:
|
||||
let target = self.state.remoteTransport == .ssh ? self.state.remoteTarget : self.state.remoteUrl
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return "Enter a remote endpoint so this Mac app can attach cleanly."
|
||||
}
|
||||
return "\(self.controlStatusLine) · \(trimmed)"
|
||||
case .unconfigured:
|
||||
return "Choose local or remote before the app can attach to a Gateway."
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayModeGroup: some View {
|
||||
SettingsCardGroup("Gateway") {
|
||||
SettingsCardRow(
|
||||
title: "OpenClaw runs",
|
||||
subtitle: "Pick whether this app owns a local Gateway or attaches to another host.",
|
||||
showsDivider: self.state.connectionMode == .unconfigured)
|
||||
{
|
||||
Picker("Gateway location", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
self.healthRow
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .trailing)
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
SettingsCardRow(
|
||||
title: "Setup needed",
|
||||
subtitle: "Local is best for this Mac. Remote is best when the Gateway already runs on a Mac Studio or server.",
|
||||
showsDivider: false)
|
||||
{
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var localGatewayGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsCardGroup("Local Gateway") {
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
}
|
||||
self.healthRow
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.remoteTransportRow
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsCardGroup("Remote Access") {
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
}
|
||||
self.remoteTokenRow
|
||||
}
|
||||
|
||||
SettingsCardGroup("Discovery & Status") {
|
||||
self.remoteDiscoveryRow
|
||||
self.remoteStatusRow
|
||||
self.controlChannelRow
|
||||
self.remoteTipRow
|
||||
}
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
self.remoteAdvancedGroup
|
||||
}
|
||||
self.remoteTokenRow
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
private var remoteDiscoveryRow: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Nearby gateways")
|
||||
.font(.callout.weight(.medium))
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget,
|
||||
@@ -211,90 +381,111 @@ struct GeneralSettings: View {
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/OpenClaw.app/.../openclaw", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil {
|
||||
let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine
|
||||
let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" }
|
||||
let line = [status, ping].compactMap(\.self).joined(separator: " · ")
|
||||
if !line.isEmpty {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
Text(authLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@ViewBuilder
|
||||
private var remoteStatusRow: some View {
|
||||
if self.remoteStatus != .idle {
|
||||
SettingsCardRow(title: "Remote test") {
|
||||
self.remoteStatusView
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
private var controlChannelRow: some View {
|
||||
SettingsCardRow(title: "Control channel", subtitle: self.controlChannelSubtitle) {
|
||||
Text(self.controlStatusLine)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(self.connectionStatusTint.opacity(0.16), in: Capsule())
|
||||
.foregroundStyle(self.connectionStatusTint)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlChannelSubtitle: String? {
|
||||
var parts: [String] = []
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
parts.append("Ping \(Int(ping)) ms")
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
parts.append("Last heartbeat \(hb.status) · \(ageText)")
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
parts.append(authLabel)
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private var remoteTipRow: some View {
|
||||
SettingsCardRow(
|
||||
title: "Recommended setup",
|
||||
subtitle: self.state.remoteTransport == .ssh
|
||||
? "Use Tailscale plus an SSH tunnel for stable private access."
|
||||
: "Use Tailscale Serve so the gateway has a valid HTTPS certificate.",
|
||||
showsDivider: false)
|
||||
{
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteAdvancedGroup: some View {
|
||||
SettingsCardGroup("Advanced") {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.advancedTextField(
|
||||
"Identity file",
|
||||
placeholder: "/Users/you/.ssh/id_ed25519",
|
||||
text: self.$state.remoteIdentity)
|
||||
self.advancedTextField(
|
||||
"Project root",
|
||||
placeholder: "/home/you/Projects/openclaw",
|
||||
text: self.$state.remoteProjectRoot)
|
||||
self.advancedTextField(
|
||||
"CLI path",
|
||||
placeholder: "/Applications/OpenClaw.app/.../openclaw",
|
||||
text: self.$state.remoteCliPath)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Text("SSH command details")
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
private func advancedTextField(_ title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteTransportRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
SettingsCardRow(
|
||||
title: "Transport",
|
||||
subtitle: "SSH keeps the Gateway private; direct is best for HTTPS or Tailscale Serve.")
|
||||
{
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 320)
|
||||
.frame(width: 320)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,34 +494,29 @@ struct GeneralSettings: View {
|
||||
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
|
||||
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "SSH target", subtitle: "User and host for the remote Gateway machine.") {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 420)
|
||||
self.remoteTestButton(disabled: !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 420)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
@@ -338,24 +524,22 @@ struct GeneralSettings: View {
|
||||
"Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteTokenRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway token")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(
|
||||
title: "Gateway token",
|
||||
subtitle: "Used when the remote gateway requires token auth.",
|
||||
showsDivider: false)
|
||||
{
|
||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 520)
|
||||
}
|
||||
Text("Used when the remote gateway requires token auth.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
if self.state.remoteTokenUnsupported {
|
||||
Text(
|
||||
"The current gateway.remote.token value is not plain text. "
|
||||
@@ -363,7 +547,8 @@ struct GeneralSettings: View {
|
||||
+ "enter a plaintext token here to replace it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,11 +605,6 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isControlStatusDuplicate: Bool {
|
||||
guard case let .failed(message) = self.remoteStatus else { return false }
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
@@ -48,6 +48,98 @@ struct SettingsSection<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCardGroup<Content: View>: View {
|
||||
let title: String
|
||||
let content: Content
|
||||
|
||||
init(_ title: String, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
self.content
|
||||
}
|
||||
.background(.quaternary.opacity(0.38), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.055))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCardRow<Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
var showsDivider = true
|
||||
let content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
showsDivider: Bool = true,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.showsDivider = showsDivider
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.callout.weight(.medium))
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 18)
|
||||
|
||||
self.content
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.overlay(alignment: .bottom) {
|
||||
if self.showsDivider {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCardToggleRow: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
@Binding var binding: Bool
|
||||
var showsDivider = true
|
||||
|
||||
var body: some View {
|
||||
SettingsCardRow(
|
||||
title: self.title,
|
||||
subtitle: self.subtitle,
|
||||
showsDivider: self.showsDivider)
|
||||
{
|
||||
Toggle(self.title, isOn: self.$binding)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsToggleRow: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
|
||||
@@ -50,6 +50,16 @@ struct SettingsRootView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(SettingsWindowChromeConfigurator())
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
NSApp.sendAction(#selector(NSSplitViewController.toggleSidebar(_:)), to: nil, from: nil)
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
}
|
||||
.help("Toggle Sidebar")
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
|
||||
if let tab = note.object as? SettingsTab {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) {
|
||||
@@ -280,72 +290,23 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
}
|
||||
|
||||
private struct SettingsWindowChromeConfigurator: NSViewRepresentable {
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView(frame: .zero)
|
||||
self.configureWindow(for: view, coordinator: context.coordinator)
|
||||
self.configureWindow(for: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
self.configureWindow(for: nsView, coordinator: context.coordinator)
|
||||
self.configureWindow(for: nsView)
|
||||
}
|
||||
|
||||
private func configureWindow(for view: NSView, coordinator: Coordinator) {
|
||||
private func configureWindow(for view: NSView) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.styleMask.remove(.fullSizeContentView)
|
||||
window.titleVisibility = .visible
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.toolbarStyle = .unifiedCompact
|
||||
coordinator.installToolbar(on: window)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSToolbarDelegate {
|
||||
private static let toolbarIdentifier = NSToolbar.Identifier("OpenClawSettingsToolbar")
|
||||
private let items: [NSToolbarItem.Identifier] = [
|
||||
.toggleSidebar,
|
||||
.flexibleSpace,
|
||||
]
|
||||
|
||||
func installToolbar(on window: NSWindow) {
|
||||
if window.toolbar?.identifier == Self.toolbarIdentifier {
|
||||
return
|
||||
}
|
||||
|
||||
let toolbar = NSToolbar(identifier: Self.toolbarIdentifier)
|
||||
toolbar.delegate = self
|
||||
toolbar.displayMode = .iconOnly
|
||||
window.toolbar = toolbar
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
self.items
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
self.items
|
||||
}
|
||||
|
||||
func toolbar(
|
||||
_ toolbar: NSToolbar,
|
||||
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
|
||||
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem?
|
||||
{
|
||||
guard itemIdentifier == .toggleSidebar else { return nil }
|
||||
let item = NSToolbarItem(itemIdentifier: .toggleSidebar)
|
||||
item.label = "Toggle Sidebar"
|
||||
item.paletteLabel = "Toggle Sidebar"
|
||||
item.toolTip = "Toggle Sidebar"
|
||||
item.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar")
|
||||
item.action = #selector(NSSplitViewController.toggleSidebar(_:))
|
||||
item.target = nil
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user