From 84399e62ae59023b4a090f210585a4925d0a39bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 01:18:42 +0000 Subject: [PATCH] fix(mac): render context sessions card with labels --- .../Sources/Clawdis/MenuContentView.swift | 96 +++++++++++-------- .../Sources/Clawdis/MenuHostedItem.swift | 30 ++++++ 2 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/MenuHostedItem.swift diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 49907b568..e9e65e27c 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -18,12 +18,12 @@ struct MenuContent: View { @State private var sessionMenu: [SessionRow] = [] @State private var contextSessions: [SessionRow] = [] @State private var contextActiveCount: Int = 0 - @State private var contextCardWidth: CGFloat = 280 + @State private var contextCardWidth: CGFloat = 320 private let activeSessionWindowSeconds: TimeInterval = 24 * 60 * 60 private let contextCardPadding: CGFloat = 10 - private let contextBarHeight: CGFloat = 6 - private let contextFallbackWidth: CGFloat = 280 + private let contextBarHeight: CGFloat = 4 + private let contextFallbackWidth: CGFloat = 320 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -259,43 +259,9 @@ struct MenuContent: View { @ViewBuilder private var contextCardRow: some View { - Button(action: {}, label: { - VStack(alignment: .leading, spacing: 10) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - - if self.contextSessions.isEmpty { - Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - ForEach(self.contextSessions) { row in - self.contextSessionRow(row) - } - } - } - } - .padding(self.contextCardPadding) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.04)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) - } - } - .onWidthChange { width in - // Keep a stable width; menu measurement can be noisy across opens. - let next = max(self.contextFallbackWidth, width) - if abs(next - self.contextCardWidth) > 1 { - self.contextCardWidth = next - } - } - }) - .buttonStyle(.plain) - .disabled(true) + MenuHostedItem( + width: self.contextCardWidth, + rootView: AnyView(self.contextCardView)) } private var contextPillWidth: CGFloat { @@ -303,6 +269,49 @@ struct MenuContent: View { return max(1, base - (self.contextCardPadding * 2)) } + @ViewBuilder + private var contextCardView: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.contextSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.contextSessions.isEmpty { + Text("No active sessions") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(self.contextSessions) { row in + self.contextSessionRow(row) + } + } + } + } + .padding(self.contextCardPadding) + .frame(width: self.contextCardWidth, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.04)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) + } + } + } + + private var contextSubtitle: String { + let count = self.contextActiveCount + if count == 1 { return "1 session · 24h" } + return "\(count) sessions · 24h" + } + @ViewBuilder private func contextSessionRow(_ row: SessionRow) -> some View { let width = self.contextPillWidth @@ -497,8 +506,13 @@ struct MenuContent: View { } let activeCount = active.count + let main = rows.first(where: { $0.key == "main" }) + var merged = active + if let main, !merged.contains(where: { $0.key == "main" }) { + merged.insert(main, at: 0) + } // Keep stable ordering: main first, then most recent. - let sorted = active.sorted { lhs, rhs in + let sorted = merged.sorted { lhs, rhs in if lhs.key == "main" { return true } if rhs.key == "main" { return false } return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) diff --git a/apps/macos/Sources/Clawdis/MenuHostedItem.swift b/apps/macos/Sources/Clawdis/MenuHostedItem.swift new file mode 100644 index 000000000..ddd4ec2fe --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuHostedItem.swift @@ -0,0 +1,30 @@ +import AppKit +import SwiftUI + +/// Hosts arbitrary SwiftUI content as an AppKit view so it can be embedded in a native `NSMenuItem.view`. +/// +/// SwiftUI `MenuBarExtraStyle.menu` aggressively simplifies many view hierarchies into a title + image. +/// Wrapping the content in an `NSViewRepresentable` forces AppKit-backed menu item rendering. +struct MenuHostedItem: NSViewRepresentable { + let width: CGFloat + let rootView: AnyView + + func makeNSView(context _: Context) -> NSHostingView { + let hosting = NSHostingView(rootView: self.rootView) + self.applySizing(to: hosting) + return hosting + } + + func updateNSView(_ nsView: NSHostingView, context _: Context) { + nsView.rootView = self.rootView + self.applySizing(to: nsView) + } + + private func applySizing(to hosting: NSHostingView) { + let width = max(1, self.width) + hosting.frame.size.width = width + let fitting = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height)) + } +} +