From 3ed01adabc68665f74c0750340e7813f424876d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 18:29:47 +0100 Subject: [PATCH] feat(macos): add session previews in menu --- CHANGELOG.md | 1 + .../Clawdis/MenuSessionsInjector.swift | 52 ++++ .../Clawdis/SessionMenuLabelView.swift | 2 +- .../Clawdis/SessionMenuPreviewView.swift | 265 ++++++++++++++++++ 4 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 70887b0cc..238a7f9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. - macOS: keep config writes on the main actor to satisfy Swift concurrency rules. - macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. +- macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 312744219..03182bbf5 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -300,6 +300,25 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return item } + private func makeSessionPreviewItem( + sessionKey: String, + title: String, + width: CGFloat, + maxLines: Int) -> NSMenuItem + { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + let view = AnyView(SessionMenuPreviewView( + sessionKey: sessionKey, + width: width, + maxItems: 10, + maxLines: maxLines, + title: title)) + item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) + return item + } + private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { let view = AnyView( Label(text, systemImage: symbolName) @@ -361,6 +380,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu { let menu = NSMenu() + let width = self.submenuWidth() + + menu.addItem(self.makeSessionPreviewItem( + sessionKey: row.key, + title: "Recent messages (last 10)", + width: width, + maxLines: 3)) + + let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") + morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) + menu.addItem(morePreview) + + menu.addItem(NSMenuItem.separator()) let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "") thinking.submenu = self.buildThinkingMenu(for: row) @@ -455,6 +487,16 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return menu } + private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { + let menu = NSMenu() + menu.addItem(self.makeSessionPreviewItem( + sessionKey: sessionKey, + title: "Recent messages (expanded)", + width: width, + maxLines: 8)) + return menu + } + private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu { let menu = NSMenu() for entry in entries { @@ -705,6 +747,16 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return self.currentMenuWidth(for: menu) } + private func submenuWidth() -> CGFloat { + if let openWidth = self.menuOpenWidth { + return max(300, openWidth) + } + if let cached = self.lastKnownMenuWidth { + return max(300, cached) + } + return self.fallbackWidth + } + private func menuWindowWidth(for menu: NSMenu) -> CGFloat? { var menuWindow: NSWindow? for item in menu.items { diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index 7d1ad5ca8..680c381f3 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -45,7 +45,7 @@ struct SessionMenuLabelView: View { Spacer(minLength: 8) - Text(self.row.tokens.contextSummaryShort) + Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") .font(.caption.monospacedDigit()) .foregroundStyle(self.secondaryTextColor) .lineLimit(1) diff --git a/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift new file mode 100644 index 000000000..371b6900b --- /dev/null +++ b/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift @@ -0,0 +1,265 @@ +import ClawdisChatUI +import ClawdisKit +import SwiftUI + +private struct SessionPreviewItem: Identifiable, Sendable { + let id: String + let role: PreviewRole + let text: String +} + +private enum PreviewRole: String, Sendable { + case user + case assistant + case tool + case system + case other + + var label: String { + switch self { + case .user: "User" + case .assistant: "Agent" + case .tool: "Tool" + case .system: "System" + case .other: "Other" + } + } +} + +private actor SessionPreviewCache { + static let shared = SessionPreviewCache() + + private struct CacheEntry { + let items: [SessionPreviewItem] + let updatedAt: Date + } + + private var entries: [String: CacheEntry] = [:] + + func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? { + guard let entry = self.entries[sessionKey] else { return nil } + guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } + return entry.items + } + + func store(items: [SessionPreviewItem], for sessionKey: String) { + self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date()) + } +} + +struct SessionMenuPreviewView: View { + let sessionKey: String + let width: CGFloat + let maxItems: Int + let maxLines: Int + let title: String + + @Environment(\.menuItemHighlighted) private var isHighlighted + @State private var items: [SessionPreviewItem] = [] + @State private var status: LoadStatus = .loading + + private enum LoadStatus: Equatable { + case loading + case ready + case empty + case error(String) + } + + private var primaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + Spacer(minLength: 8) + } + + switch self.status { + case .loading: + Text("Loading preview…") + .font(.caption) + .foregroundStyle(self.secondaryColor) + case .empty: + Text("No recent messages") + .font(.caption) + .foregroundStyle(self.secondaryColor) + case let .error(message): + Text(message) + .font(.caption) + .foregroundStyle(self.secondaryColor) + case .ready: + VStack(alignment: .leading, spacing: 6) { + ForEach(self.items) { item in + self.previewRow(item) + } + } + } + } + .padding(.vertical, 6) + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(width: max(1, self.width), alignment: .leading) + .task(id: self.sessionKey) { + await self.loadPreview() + } + } + + @ViewBuilder + private func previewRow(_ item: SessionPreviewItem) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(item.role.label) + .font(.caption2.monospacedDigit()) + .foregroundStyle(self.roleColor(item.role)) + .frame(width: 50, alignment: .leading) + + Text(item.text) + .font(.caption) + .foregroundStyle(self.primaryColor) + .multilineTextAlignment(.leading) + .lineLimit(self.maxLines) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func roleColor(_ role: PreviewRole) -> Color { + if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) } + switch role { + case .user: return .accentColor + case .assistant: return .secondary + case .tool: return .orange + case .system: return .gray + case .other: return .secondary + } + } + + private func loadPreview() async { + if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) { + await MainActor.run { + self.items = cached + self.status = cached.isEmpty ? .empty : .ready + } + return + } + + await MainActor.run { + self.status = .loading + } + + do { + let payload = try await GatewayConnection.shared.chatHistory(sessionKey: self.sessionKey) + let built = Self.previewItems(from: payload, maxItems: self.maxItems) + await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) + await MainActor.run { + self.items = built + self.status = built.isEmpty ? .empty : .ready + } + } catch { + await MainActor.run { + self.status = .error("Preview unavailable") + } + } + } + + private static func previewItems( + from payload: ClawdisChatHistoryPayload, + maxItems: Int) -> [SessionPreviewItem] + { + let messages = self.decodeMessages(payload.messages ?? []) + let built = messages.compactMap { message -> SessionPreviewItem? in + guard let text = self.previewText(for: message) else { return nil } + let isTool = self.isToolCall(message) + let role = self.previewRole(message.role, isTool: isTool) + let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" + return SessionPreviewItem(id: id, role: role, text: text) + } + + let trimmed = built.suffix(maxItems) + return Array(trimmed.reversed()) + } + + private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] { + raw.compactMap { item in + (try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self)) + } + } + + private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { + if isTool { return .tool } + switch raw.lowercased() { + case "user": return .user + case "assistant": return .assistant + case "system": return .system + case "tool": return .tool + default: return .other + } + } + + private static func previewText(for message: ClawdisChatMessage) -> String? { + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + + let toolNames = self.toolNames(for: message) + if !toolNames.isEmpty { + let shown = toolNames.prefix(2) + let overflow = toolNames.count - shown.count + var label = "call \(shown.joined(separator: ", "))" + if overflow > 0 { label += " +\(overflow)" } + return label + } + + if let media = self.mediaSummary(for: message) { + return media + } + + return nil + } + + private static func isToolCall(_ message: ClawdisChatMessage) -> Bool { + if message.toolName?.nonEmpty != nil { return true } + return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } + } + + private static func toolNames(for message: ClawdisChatMessage) -> [String] { + var names: [String] = [] + for content in message.content { + if let name = content.name?.nonEmpty { + names.append(name) + } + } + if let toolName = message.toolName?.nonEmpty { + names.append(toolName) + } + return Self.dedupePreservingOrder(names) + } + + private static func mediaSummary(for message: ClawdisChatMessage) -> String? { + let types = message.content.compactMap { content -> String? in + let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, !raw.isEmpty else { return nil } + if raw == "text" || raw == "toolcall" { return nil } + return raw + } + guard let first = types.first else { return nil } + return "[\(first)]" + } + + private static func dedupePreservingOrder(_ values: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for value in values where !seen.contains(value) { + seen.insert(value) + result.append(value) + } + return result + } +}