fix(macos): show sessions after controls in tray menu (#38079)

* fix(macos): show sessions after controls in tray menu

When many sessions are active, the injected session rows push the
toggles, action buttons, and settings items off-screen, requiring
a scroll to reach them.

Change findInsertIndex and findNodesInsertIndex to anchor just before
the separator above 'Settings…' instead of before 'Send Heartbeats'.
This ensures the controls section is always immediately visible on
menu open, with sessions appearing below.

* refactor: extract findAnchoredInsertIndex to eliminate duplication

findInsertIndex and findNodesInsertIndex shared identical logic.
Extract into a single private helper so any future anchor change
(e.g. Settings item title) only needs one edit.

* macOS: use structural tray menu anchor

---------

Co-authored-by: Brian Ernesto <bernesto@users.noreply.github.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
This commit is contained in:
Brian Ernesto 2026-03-17 18:29:11 -06:00 committed by GitHub
parent 7dabcf287d
commit ab1da26f4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 59 additions and 24 deletions

View File

@ -647,6 +647,7 @@ Docs: https://docs.openclaw.ai
- Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
- Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto.
- Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.

View File

@ -1099,38 +1099,33 @@ extension MenuSessionsInjector {
// MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? {
// Insert right before the separator above "Send Heartbeats".
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
self.findDynamicSectionInsertIndex(in: menu)
}
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
self.findDynamicSectionInsertIndex(in: menu)
}
private func findDynamicSectionInsertIndex(in menu: NSMenu) -> Int? {
// Keep controls and action buttons visible by inserting dynamic rows at the
// built-in footer boundary, not by matching localized menu item titles.
if let footerSeparatorIndex = menu.items.lastIndex(where: { item in
item.isSeparatorItem && !self.isInjectedItem(item)
}) {
return footerSeparatorIndex
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) {
return min(firstBaseItemIndex + 1, menu.items.count)
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
}
private func isInjectedItem(_ item: NSMenuItem) -> Bool {
item.tag == self.tag || item.tag == self.nodesTag
}
private func initialWidth(for menu: NSMenu) -> CGFloat {
if let openWidth = self.menuOpenWidth {
return max(300, openWidth)
@ -1236,5 +1231,13 @@ extension MenuSessionsInjector {
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
self.findInsertIndex(in: menu)
}
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
self.findNodesInsertIndex(in: menu)
}
}
#endif

View File

@ -5,7 +5,26 @@ import Testing
@Suite(.serialized)
@MainActor
struct MenuSessionsInjectorTests {
@Test func `injects disconnected message`() {
@Test func anchorsDynamicRowsBelowControlsAndActions() throws {
let injector = MenuSessionsInjector()
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem }))
#expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex)
#expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex)
}
@Test func injectsDisconnectedMessage() {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(false)
injector.setTestingSnapshot(nil, errorText: nil)
@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests {
#expect(menu.items.contains { $0.tag == 9_415_557 })
}
@Test func `injects session rows`() {
@Test func injectsSessionRows() throws {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests {
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
injector.injectForTesting(into: menu)
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
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 }))
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" }))
#expect(sendHeartbeatsIndex < firstInjectedIndex)
#expect(openDashboardIndex < firstInjectedIndex)
#expect(firstInjectedIndex < settingsIndex)
}
@Test func `cost usage submenu does not use injector delegate`() {