From 23ed86b36438b4bf1992447fe0dd6b17d3c726cf Mon Sep 17 00:00:00 2001 From: LHagfoss Date: Tue, 20 Oct 2026 11:44:56 +0200 Subject: [PATCH] fix(macos): close panels on outside click and quiet accent colors - Settings, rename, /context and command answers open as panels over the dimmed window that a click outside or Esc closes; Settings is larger. - Settings sections use plain SF Symbols instead of coloured tiles. - Approval and question cards use Liquid Glass with a neutral hairline instead of an amber outline; questions take the brand colour and list their choices as rows. - Composer panels are a little wider. Closes #2180 Claude-Session: https://claude.ai/code/session_01SZ3DB3WXnKyfSuHppCNJFg --- macos/Sources/RustCode/AttentionCards.swift | 53 ++++++++++++++---- macos/Sources/RustCode/ContentView.swift | 19 +++---- macos/Sources/RustCode/ContextSheet.swift | 6 +- macos/Sources/RustCode/Panels.swift | 62 +++++++++++++++++++++ macos/Sources/RustCode/SettingsView.swift | 32 ++++------- macos/Sources/RustCode/Toasts.swift | 4 +- 6 files changed, 129 insertions(+), 47 deletions(-) create mode 100644 macos/Sources/RustCode/Panels.swift diff --git a/macos/Sources/RustCode/AttentionCards.swift b/macos/Sources/RustCode/AttentionCards.swift index 3e5e3d6a..78e6579d 100644 --- a/macos/Sources/RustCode/AttentionCards.swift +++ b/macos/Sources/RustCode/AttentionCards.swift @@ -44,8 +44,7 @@ struct PermissionCard: View { .controlSize(.large) } .padding(16) - .floatingSurface(in: .rect(cornerRadius: 20)) - .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(Color.attention.opacity(0.5), lineWidth: 1) } + .attentionCard() } } @@ -60,9 +59,10 @@ struct QuestionCard: View { var body: some View { let question = request.question! VStack(alignment: .leading, spacing: 12) { + // A question is a conversation, not a warning: the brand, not amber. Label(question.header.isEmpty ? "RustCode has a question" : question.header, systemImage: "questionmark.bubble.fill") .font(.headline) - .foregroundStyle(Color.attention) + .foregroundStyle(Color.brand) Text(question.text).textSelection(.enabled) if question.multiple { VStack(alignment: .leading, spacing: 6) { @@ -74,13 +74,9 @@ struct QuestionCard: View { } } } else { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { ForEach(request.answerOptions) { option in - Button { Task { await model.answer(request, with: option) } } label: { - Text(option.name).frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - .controlSize(.large) + AnswerRow(title: option.name) { Task { await model.answer(request, with: option) } } } } } @@ -108,7 +104,42 @@ struct QuestionCard: View { } } .padding(16) - .floatingSurface(in: .rect(cornerRadius: 20)) - .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(Color.attention.opacity(0.5), lineWidth: 1) } + .attentionCard() + } +} + +/// One choice of a question: a full-width row that lights under the pointer. +private struct AnswerRow: View { + let title: String + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack { + Text(title).multilineTextAlignment(.leading) + Spacer(minLength: 8) + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(Color.brand) + .opacity(isHovered ? 1 : 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isHovered ? AnyShapeStyle(Color.brand.opacity(0.14)) : AnyShapeStyle(.fill.quaternary), + in: .rect(cornerRadius: 12)) + .contentShape(.rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +private extension View { + /// An approval or question above the composer: Liquid Glass with a + /// neutral hairline. The header's symbol carries the state, not the edge. + func attentionCard() -> some View { + glassSurface(in: .rect(cornerRadius: 20)) + .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(Color.primary.opacity(0.1), lineWidth: 1) } } } diff --git a/macos/Sources/RustCode/ContentView.swift b/macos/Sources/RustCode/ContentView.swift index 16e10c4f..70d7eb68 100644 --- a/macos/Sources/RustCode/ContentView.swift +++ b/macos/Sources/RustCode/ContentView.swift @@ -32,10 +32,10 @@ struct ContentView: View { .ignoresSafeArea() .tint(.brand) .background(WindowChrome()) - .sheet(isPresented: $model.showsSettings) { SettingsView(model: model) } - .sheet(item: $model.renamingSession) { RenameSheet(model: model, session: $0) } - .sheet(item: $model.contextSheet) { ContextSheet(context: $0) } - .sheet(item: $model.commandResult) { CommandResultSheet(result: $0) } + .clickAwayPanel(isPresented: $model.showsSettings) { SettingsView(model: model) } + .clickAwayPanel(item: $model.renamingSession) { RenameSheet(model: model, session: $0) } + .clickAwayPanel(item: $model.contextSheet) { ContextSheet(context: $0) } + .clickAwayPanel(item: $model.commandResult) { CommandResultSheet(result: $0) } .overlay { ToastStack(model: model) } } } @@ -359,7 +359,7 @@ struct RenameSheet: View { let session: SessionSummary @State private var title: String @FocusState private var focused: Bool - @Environment(\.dismiss) private var dismiss + @Environment(\.closePanel) private var dismiss init(model: DesktopModel, session: SessionSummary) { self.model = model @@ -381,15 +381,14 @@ struct RenameSheet: View { Spacer() Button("Cancel") { dismiss() } .buttonStyle(PillButtonStyle()) - .keyboardShortcut(.cancelAction) Button("Rename", action: save) .buttonStyle(PillButtonStyle(prominent: true)) .keyboardShortcut(.defaultAction) .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty) } } - .padding(20) - .frame(width: 380) + .padding(22) + .frame(width: 420) .onAppear { focused = true } } @@ -729,7 +728,7 @@ private struct ChatView: View { } } .padding(6) - .frame(width: panel == .model ? 340 : panel == .effort ? 300 : 260, alignment: .leading) + .frame(width: panel == .model ? 380 : panel == .effort ? 330 : 290, alignment: .leading) .glassSurface(in: .rect(cornerRadius: 20)) if panel == .add { Spacer(minLength: 0) } } @@ -1715,7 +1714,7 @@ struct PopoverRow: View { } } .padding(.horizontal, 10) - .padding(.vertical, 7) + .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(isHighlighted ? AnyShapeStyle(Color.brand.opacity(0.2)) : isHovered ? AnyShapeStyle(.fill.tertiary) : AnyShapeStyle(.clear), in: .rect(cornerRadius: 14)) diff --git a/macos/Sources/RustCode/ContextSheet.swift b/macos/Sources/RustCode/ContextSheet.swift index 040b7d7b..2080f7e6 100644 --- a/macos/Sources/RustCode/ContextSheet.swift +++ b/macos/Sources/RustCode/ContextSheet.swift @@ -6,7 +6,7 @@ import SwiftUI /// same breakdown the terminal's context panel shows. struct ContextSheet: View { let context: ContextBreakdown - @Environment(\.dismiss) private var dismiss + @Environment(\.closePanel) private var dismiss /// One colour per category, in the engine's legend order. static let palette: [Color] = [.blue, .purple, .orange, .teal, .indigo, .pink, .green] @@ -32,8 +32,8 @@ struct ContextSheet: View { .keyboardShortcut(.defaultAction) } } - .padding(20) - .frame(width: 440) + .padding(22) + .frame(width: 500) } private var header: some View { diff --git a/macos/Sources/RustCode/Panels.swift b/macos/Sources/RustCode/Panels.swift new file mode 100644 index 00000000..f5d713c4 --- /dev/null +++ b/macos/Sources/RustCode/Panels.swift @@ -0,0 +1,62 @@ +import SwiftUI + +private struct ClosePanelKey: EnvironmentKey { + static let defaultValue: @MainActor () -> Void = {} +} + +extension EnvironmentValues { + /// Closes the panel a view is shown in. Panels are overlays, not sheets, + /// so `dismiss` does not reach them. + var closePanel: @MainActor () -> Void { + get { self[ClosePanelKey.self] } + set { self[ClosePanelKey.self] = newValue } + } +} + +/// A panel over the window, such as Settings: a click on the dimmed window +/// around it or Esc closes it, as a popover does. A sheet ignores clicks +/// outside it. +private struct ClickAwayPanel: ViewModifier { + let isPresented: Bool + let close: @MainActor () -> Void + @ViewBuilder let panel: () -> Panel + + func body(content: Content) -> some View { + content.overlay { + ZStack { + if isPresented { + Color.black.opacity(0.22) + .ignoresSafeArea() + .contentShape(.rect) + .onTapGesture { close() } + .transition(.opacity) + panel() + .environment(\.closePanel, close) + .background(Color.chatBackground) + .clipShape(.rect(cornerRadius: 16)) + .overlay { RoundedRectangle(cornerRadius: 16).strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) } + .shadow(color: .black.opacity(0.22), radius: 28, y: 12) + .padding(28) + .background { + // Esc closes the panel from wherever focus is. + Button("Close", action: close).keyboardShortcut(.cancelAction).hidden() + } + .transition(.scale(scale: 0.97).combined(with: .opacity)) + } + } + .animation(.snappy(duration: 0.2), value: isPresented) + } + } +} + +extension View { + func clickAwayPanel(isPresented: Binding, @ViewBuilder content: @escaping () -> Panel) -> some View { + modifier(ClickAwayPanel(isPresented: isPresented.wrappedValue, close: { isPresented.wrappedValue = false }, panel: content)) + } + + func clickAwayPanel(item: Binding, @ViewBuilder content: @escaping (Item) -> Panel) -> some View { + modifier(ClickAwayPanel(isPresented: item.wrappedValue != nil, close: { item.wrappedValue = nil }) { + if let value = item.wrappedValue { content(value) } + }) + } +} diff --git a/macos/Sources/RustCode/SettingsView.swift b/macos/Sources/RustCode/SettingsView.swift index 827eb7c9..5943c9d4 100644 --- a/macos/Sources/RustCode/SettingsView.swift +++ b/macos/Sources/RustCode/SettingsView.swift @@ -20,7 +20,7 @@ enum AppearanceSetting { /// ⌘, — a sheet over the chat: sections on the left, the chosen one on the right. struct SettingsView: View { @ObservedObject var model: DesktopModel - @Environment(\.dismiss) private var dismiss + @Environment(\.closePanel) private var dismiss @StateObject private var capabilities = CapabilitiesStore() typealias Section = SettingsSection @@ -40,7 +40,7 @@ struct SettingsView: View { } .padding(12) .padding(.top, 8) - .frame(width: 200) + .frame(width: 220) .background(Color.sidebarBackground) VStack(alignment: .leading, spacing: 0) { @@ -48,7 +48,6 @@ struct SettingsView: View { Text(section.rawValue).font(.title2.weight(.semibold)) Spacer() ChromeButton(systemImage: "xmark", help: "Close (Esc)") { dismiss() } - .keyboardShortcut(.cancelAction) } .padding(.horizontal, 24) .padding(.top, 20) @@ -70,7 +69,7 @@ struct SettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color.chatBackground) } - .frame(width: 720, height: 480) + .frame(maxWidth: 880, maxHeight: 620) .tint(.brand) .task(id: section) { if section == .skills || section == .mcp { await capabilities.load(model.engineConnection) } @@ -87,11 +86,12 @@ private struct SectionRow: View { var body: some View { Button(action: action) { HStack(spacing: 10) { + // A plain symbol, as in the window's sidebar: no coloured tile. Image(systemName: section.systemImage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 24, height: 24) - .background(section.tile.gradient, in: .rect(cornerRadius: 6)) + .font(.system(size: 14, weight: .medium)) + .symbolRenderingMode(.monochrome) + .foregroundStyle(isSelected ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.secondary)) + .frame(width: 22, height: 22) Text(section.rawValue) Spacer() } @@ -397,21 +397,11 @@ enum SettingsSection: String, CaseIterable, Identifiable { var systemImage: String { switch self { - case .general: "gearshape.fill" + case .general: "gearshape" case .skills: "sparkles" case .mcp: "point.3.connected.trianglepath.dotted" - case .engine: "terminal.fill" - case .about: "info.circle.fill" - } - } - - var tile: Color { - switch self { - case .general: .gray - case .skills: .purple - case .mcp: .teal - case .engine: .orange - case .about: .blue + case .engine: "terminal" + case .about: "info.circle" } } } diff --git a/macos/Sources/RustCode/Toasts.swift b/macos/Sources/RustCode/Toasts.swift index aeb28d36..e6fe4ec7 100644 --- a/macos/Sources/RustCode/Toasts.swift +++ b/macos/Sources/RustCode/Toasts.swift @@ -176,7 +176,7 @@ private struct ToastView: View { /// A host command's full answer in a sheet, with its Markdown rendered. struct CommandResultSheet: View { let result: CommandResult - @Environment(\.dismiss) private var dismiss + @Environment(\.closePanel) private var dismiss var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -199,6 +199,6 @@ struct CommandResultSheet: View { .padding(16) } } - .frame(minWidth: 520, idealWidth: 620, minHeight: 280, idealHeight: 480) + .frame(maxWidth: 700, maxHeight: 560) } }