From bbf54505728a272b8c5244c074031bce92d4a8eb Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 1 May 2026 09:57:22 +0300 Subject: [PATCH] refactor(macos): move sessions into context submenu --- CHANGELOG.md | 1 + .../OpenClaw/ContextRootMenuLabelView.swift | 39 +++ .../OpenClaw/MenuSessionsInjector.swift | 228 +++++++++++------- .../MenuSessionsInjectorTests.swift | 12 +- docs/platforms/mac/menu-bar.md | 13 +- 5 files changed, 200 insertions(+), 93 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ContextRootMenuLabelView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7ddf8e810..e3664c9f372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP. +- macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti. - Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box. - Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok. - BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris. diff --git a/apps/macos/Sources/OpenClaw/ContextRootMenuLabelView.swift b/apps/macos/Sources/OpenClaw/ContextRootMenuLabelView.swift new file mode 100644 index 00000000000..ec13d8148ef --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ContextRootMenuLabelView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct ContextRootMenuLabelView: View { + let subtitle: String + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var palette: MenuItemHighlightColors.Palette { + MenuItemHighlightColors.palette(self.isHighlighted) + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Context") + .font(.callout.weight(.semibold)) + .foregroundStyle(self.palette.primary) + .lineLimit(1) + .layoutPriority(1) + + Spacer(minLength: 8) + + Text(self.subtitle) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.palette.secondary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(2) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.palette.secondary) + .padding(.leading, 2) + } + .padding(.vertical, 8) + .padding(.leading, 22) + .padding(.trailing, 14) + .frame(width: max(1, self.width), alignment: .leading) + } +} diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 693331d6f73..2dd31b69676 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -176,99 +176,31 @@ extension MenuSessionsInjector { let channelState = ControlChannel.shared.state var cursor = insertIndex - var headerView: NSView? - - if let snapshot = self.cachedSnapshot { - let now = Date() - let mainKey = self.mainSessionKey - let rows = snapshot.rows.filter { row in - if row.key == "main", mainKey != "main" { return false } - if row.key == mainKey { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds - }.sorted { lhs, rhs in - if lhs.key == mainKey { return true } - if rhs.key == mainKey { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - if !rows.isEmpty { - let previewKeys = rows.prefix(20).map(\.key) - let task = Task { - await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10) - } - self.previewTasks.append(task) - } - - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - let statusText = self - .cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) - let hosted = self.makeHostedView( - rootView: AnyView(MenuSessionsHeaderView( - count: rows.count, - statusText: statusText)), - width: width, - highlighted: false) - headerItem.view = hosted - headerView = hosted - menu.insertItem(headerItem, at: cursor) - cursor += 1 - - if rows.isEmpty { - menu.insertItem( - self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), - at: cursor) - cursor += 1 - } else { - for row in rows { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = true - item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) - item.view = self.makeHostedView( - rootView: AnyView(SessionMenuLabelView(row: row, width: width)), - width: width, - highlighted: true) - menu.insertItem(item, at: cursor) - cursor += 1 - } - } - } else { - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - let statusText = isConnected - ? (self.cachedErrorText ?? "Loading sessions…") - : self.controlChannelStatusText(for: channelState) - let hosted = self.makeHostedView( - rootView: AnyView(MenuSessionsHeaderView( - count: 0, - statusText: statusText)), - width: width, - highlighted: false) - headerItem.view = hosted - headerView = hosted - menu.insertItem(headerItem, at: cursor) - cursor += 1 - - if !isConnected { - menu.insertItem( - self.makeMessageItem( - text: "Connect the gateway to see sessions", - symbolName: "bolt.slash", - width: width), - at: cursor) - cursor += 1 - } - } + let item = NSMenuItem(title: "Context", action: nil, keyEquivalent: "") + item.tag = self.tag + item.isEnabled = true + item.submenu = self.buildContextSubmenu( + width: width, + isConnected: isConnected, + channelState: channelState) + let hosted = self.makeHostedView( + rootView: AnyView(ContextRootMenuLabelView( + subtitle: self.contextRootSubtitle( + isConnected: isConnected, + channelState: channelState), + width: width)), + width: width, + highlighted: true) + item.view = hosted + menu.insertItem(item, at: cursor) + cursor += 1 cursor = self.insertUsageSection(into: menu, at: cursor, width: width) cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width) - DispatchQueue.main.async { [weak self, weak headerView] in - guard let self, let headerView else { return } - self.captureMenuWidthIfAvailable(from: headerView) + DispatchQueue.main.async { [weak self, weak hosted] in + guard let self, let hosted else { return } + self.captureMenuWidthIfAvailable(from: hosted) } } @@ -346,6 +278,124 @@ extension MenuSessionsInjector { _ = cursor } + private func buildContextSubmenu( + width: CGFloat, + isConnected: Bool, + channelState: ControlChannel.ConnectionState) -> NSMenu + { + let menu = NSMenu() + let width = max(300, width) + var cursor = 0 + + if let snapshot = self.cachedSnapshot { + let rows = self.activeRows(from: snapshot) + if !rows.isEmpty { + let previewKeys = rows.prefix(20).map(\.key) + let task = Task { + await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10) + } + self.previewTasks.append(task) + } + + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let statusText = self.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) + headerItem.view = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView( + count: rows.count, + statusText: statusText)), + width: width, + highlighted: false) + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if rows.isEmpty { + menu.insertItem( + self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), + at: cursor) + cursor += 1 + } else { + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + item.representedObject = row.key + item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) + item.view = self.makeHostedView( + rootView: AnyView(SessionMenuLabelView(row: row, width: width)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + } + } + } else { + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let statusText = isConnected + ? (self.cachedErrorText ?? "Loading sessions…") + : self.controlChannelStatusText(for: channelState) + headerItem.view = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView( + count: 0, + statusText: statusText)), + width: width, + highlighted: false) + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if !isConnected { + menu.insertItem( + self.makeMessageItem( + text: "Connect the gateway to see sessions", + symbolName: "bolt.slash", + width: width), + at: cursor) + cursor += 1 + } + } + + _ = cursor + return menu + } + + private func contextRootSubtitle( + isConnected: Bool, + channelState: ControlChannel.ConnectionState) -> String + { + if let snapshot = self.cachedSnapshot { + return self.sessionsSubtitle(count: self.activeRows(from: snapshot).count) + } + + if isConnected { + return self.cachedErrorText ?? "Loading…" + } + + return self.controlChannelStatusText(for: channelState) + } + + private func activeRows(from snapshot: SessionStoreSnapshot) -> [SessionRow] { + let now = Date() + let mainKey = self.mainSessionKey + return snapshot.rows.filter { row in + if row.key == "main", mainKey != "main" { return false } + if row.key == mainKey { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + }.sorted { lhs, rhs in + if lhs.key == mainKey { return true } + if rhs.key == mainKey { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + } + + private func sessionsSubtitle(count: Int) -> String { + if count == 1 { return "1 session · 24h" } + return "\(count) sessions · 24h" + } + private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { let rows = self.usageRows if rows.isEmpty { diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 1b43475f0b2..eb050ce7bc2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -35,7 +35,9 @@ struct MenuSessionsInjectorTests { menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) - #expect(menu.items.contains { $0.tag == 9_415_557 }) + let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" } + #expect(contextItem != nil) + #expect(contextItem?.submenu != nil) } @Test func `injects session rows`() throws { @@ -114,8 +116,12 @@ struct MenuSessionsInjectorTests { menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) - #expect(menu.items.contains { $0.tag == 9_415_557 }) + let contextItem = try #require(menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" }) + let contextSubmenu = try #require(contextItem.submenu) + #expect(menu.items.filter { $0.tag == 9_415_557 && $0.title == "Context" }.count == 1) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) + #expect(contextSubmenu.items.compactMap { $0.representedObject as? String }.filter { ["main", "discord:group:alpha"].contains($0) }.count == 2) + #expect(contextSubmenu.items.allSatisfy { $0.title != "Usage cost (30 days)" }) let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" })) let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" })) let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 })) @@ -160,6 +166,8 @@ struct MenuSessionsInjectorTests { injector.injectForTesting(into: menu) + let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" } + #expect(contextItem?.submenu?.items.allSatisfy { $0.title != "Usage cost (30 days)" } == true) let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } #expect(usageCostItem != nil) #expect(usageCostItem?.submenu != nil) diff --git a/docs/platforms/mac/menu-bar.md b/docs/platforms/mac/menu-bar.md index b329e9f0227..057c0f40e77 100644 --- a/docs/platforms/mac/menu-bar.md +++ b/docs/platforms/mac/menu-bar.md @@ -11,8 +11,9 @@ title: "Menu bar" - We surface the current agent work state in the menu bar icon and in the first status row of the menu. - Health status is hidden while work is active; it returns when all sessions are idle. -- The “Nodes” block in the menu lists **devices** only (paired nodes via `node.list`), not client/presence entries. -- A “Usage” section appears under Context when provider usage snapshots are available. +- A root “Context” submenu contains recent sessions instead of expanding them directly in the root menu. +- The “Nodes” block in the root menu lists **devices** only (paired nodes via `node.list`), not client/presence entries. +- A root “Usage” section appears below Context when provider usage snapshots are available, followed by usage-cost details when available. ## State model @@ -45,6 +46,14 @@ title: "Menu bar" - `workingOther`: badge with glyph, muted tint, no scurry. - `overridden`: uses the chosen glyph/tint regardless of activity. +## Context submenu + +- The root menu shows one “Context” row with a session count/status and opens a submenu. +- The Context submenu header shows the active session count for the last 24 hours. +- Each session row keeps its token bar, age, preview, thinking/verbose, reset, compact, and delete actions. +- Loading, disconnected, and session-load error messages appear inside the Context submenu. +- Provider usage and usage-cost details stay root-level below Context so they remain glanceable without opening the submenu. + ## Status row text (menu) - While work is active: ` · `