[object Object]

← back to Ticket System

Ticket desktop bar: split OPEN/BLOCKED/DOING into 3 separate dropdowns; readable+clickable rows; drag-to-slide vertically

3fa9bcca8c5e352f25cae654af487420b028ca0e · 2026-09-03 10:01:00 -0700 · Steve Abrams

- Split the single combined counts tile into THREE independent color-coded tiles
  (OPEN blue / BLOCKED red / DOING light-blue); each opens its OWN dropdown listing
  that status's tickets (per Steve: 'each must open separately').
- Ticket detail rows: autoenablesItems=false + real target/action so they render as
  readable text (were greyed-out disabled items) and are clickable — jumps the
  control window to that ticket via setQ.
- Real click-vs-drag split: a clean click opens the dropdown; a VERTICAL drag slides
  the whole bar up/down (persisted); a horizontal drag on a tile reorders it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XNcHvXXepEQar7Qsb7GGy

Files touched

Diff

commit 3fa9bcca8c5e352f25cae654af487420b028ca0e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 10:01:00 2026 -0700

    Ticket desktop bar: split OPEN/BLOCKED/DOING into 3 separate dropdowns; readable+clickable rows; drag-to-slide vertically
    
    - Split the single combined counts tile into THREE independent color-coded tiles
      (OPEN blue / BLOCKED red / DOING light-blue); each opens its OWN dropdown listing
      that status's tickets (per Steve: 'each must open separately').
    - Ticket detail rows: autoenablesItems=false + real target/action so they render as
      readable text (were greyed-out disabled items) and are clickable — jumps the
      control window to that ticket via setQ.
    - Real click-vs-drag split: a clean click opens the dropdown; a VERTICAL drag slides
      the whole bar up/down (persisted); a horizontal drag on a tile reorders it.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015XNcHvXXepEQar7Qsb7GGy
---
 desktop-bar/TicketBar.swift | 117 +++++++++++++++++++++++++++++++++++++-------
 1 file changed, 99 insertions(+), 18 deletions(-)

diff --git a/desktop-bar/TicketBar.swift b/desktop-bar/TicketBar.swift
index c5b69291..83e305b8 100644
--- a/desktop-bar/TicketBar.swift
+++ b/desktop-bar/TicketBar.swift
@@ -18,6 +18,7 @@ private final class BarTile: NSButton {
   var menuProvider: (() -> NSMenu)?
   var clickHandler: (() -> Void)?
   var dragHandler: ((String, CGFloat) -> Void)?
+  var shouldSuppressClick: (() -> Bool)?
   var widthConstraint: NSLayoutConstraint!
 
   init(key: String, label: NSTextField, width: CGFloat) {
@@ -48,8 +49,10 @@ private final class BarTile: NSButton {
   required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
   override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
   @objc private func showDropdown() {
+    // A drag (vertical bar-slide or horizontal tile-reorder) sets this true so the
+    // trailing mouse-up click doesn't also pop the menu.
+    if shouldSuppressClick?() == true { return }
     NSLog("TicketBar tile pressed: %@", key)
-    if let clickHandler { clickHandler(); return }
     guard let menu = menuProvider?() else { return }
     NSApp.activate(ignoringOtherApps: true)
     menu.popUp(positioning: nil, at: NSPoint(x: 4, y: bounds.height - 2), in: self)
@@ -64,11 +67,14 @@ private final class BarTile: NSButton {
 
 private final class BarController: NSObject, NSApplicationDelegate, NSWindowDelegate, WKNavigationDelegate {
   private var panel: NSPanel!
-  private let counts = NSTextField(labelWithString: "Tickets · loading…")
+  private let openLabel = NSTextField(labelWithString: "OPEN …")
+  private let blockedLabel = NSTextField(labelWithString: "BLOCKED …")
+  private let doingLabel = NSTextField(labelWithString: "DOING …")
   private let working = NSTextField(labelWithString: "Working · —")
   private let latest = NSTextField(labelWithString: "Latest · —")
   private var stack: NSStackView!
   private var tiles: [BarTile] = []
+  private var statusDetails: [String: [String]] = [:]
   private var workingDetails: [String] = []
   private var latestDetail = "No activity yet"
   private var ticketPopup: NSWindow?
@@ -76,6 +82,10 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
   private var dragMonitor: Any?
   private var draggingKey: String?
   private var dragStartX: CGFloat?
+  private var dragStartMouseY: CGFloat?
+  private var dragStartPanelY: CGFloat?
+  private var barSlid = false
+  private var didDrag = false
 
   func applicationDidFinishLaunching(_ notification: Notification) {
     NSApp.setActivationPolicy(.accessory)
@@ -94,18 +104,25 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     panel.hasShadow = true
     panel.hidesOnDeactivate = false
 
-    for label in [counts, working, latest] {
+    let countLabels = [openLabel, blockedLabel, doingLabel]
+    for label in countLabels + [working, latest] {
       label.textColor = .white
-      label.font = .systemFont(ofSize: 12, weight: label === counts ? .bold : .medium)
+      label.font = .systemFont(ofSize: 12, weight: countLabels.contains(label) ? .bold : .medium)
       label.lineBreakMode = .byTruncatingTail
       label.maximumNumberOfLines = 1
     }
+    openLabel.textColor    = NSColor(calibratedRed: 0.31, green: 0.63, blue: 1.00, alpha: 1) // blue
+    blockedLabel.textColor = NSColor(calibratedRed: 0.88, green: 0.42, blue: 0.46, alpha: 1) // red
+    doingLabel.textColor   = NSColor(calibratedRed: 0.43, green: 0.70, blue: 0.95, alpha: 1) // light blue
     working.textColor = NSColor(calibratedRed: 0.45, green: 0.78, blue: 1, alpha: 1)
     latest.textColor = NSColor(calibratedWhite: 0.72, alpha: 1)
 
     let savedWidths = UserDefaults.standard.dictionary(forKey: "ticketBarWidths") as? [String: CGFloat] ?? [:]
+    // OPEN / BLOCKED / DOING are now THREE separate tiles — each opens its own
+    // dropdown listing that status's tickets. Working + Latest unchanged.
     let definitions: [(String, NSTextField, CGFloat)] = [
-      ("counts", counts, 285), ("working", working, 620), ("latest", latest, 560)
+      ("open", openLabel, 150), ("blocked", blockedLabel, 175), ("doing", doingLabel, 150),
+      ("working", working, 560), ("latest", latest, 520)
     ]
     let savedOrder = UserDefaults.standard.stringArray(forKey: "ticketBarOrder") ?? definitions.map(\.0)
     let byKey = Dictionary(uniqueKeysWithValues: definitions.map { ($0.0, $0) })
@@ -114,9 +131,11 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
       let tile = BarTile(key: key, label: label, width: savedWidths[key] ?? defaultWidth)
       tile.dragHandler = { [weak self] key, x in self?.moveTile(key, toward: x) }
       tile.menuProvider = { [weak self] in self?.menu(for: key) ?? NSMenu() }
-      // No clickHandler: left-click a tile now opens its DROPDOWN menu (details +
-      // layout controls). The web control window is reachable from the menu's
-      // "Open Control Window" item and from the "Open Fleet ↗" button below.
+      tile.shouldSuppressClick = { [weak self] in self?.didDrag ?? false }
+      // No clickHandler: a clean left-click opens the tile's DROPDOWN menu (details
+      // + layout controls). A vertical drag slides the whole bar; a horizontal drag
+      // on a tile reorders it. The web control window is reachable from the menu's
+      // "Open Control Window" item and from the "Open Fleet ↗" button.
       return tile
     }
     let open = NSButton(title: "Open Fleet ↗", target: self, action: #selector(openViewer))
@@ -152,24 +171,59 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
       guard let self, event.window === self.panel else { return event }
       switch event.type {
       case .leftMouseDown:
+        self.didDrag = false
+        self.barSlid = false
         self.draggingKey = self.tiles.first(where: {
           $0.bounds.contains($0.convert(event.locationInWindow, from: nil))
         })?.key
         self.dragStartX = event.locationInWindow.x
+        self.dragStartMouseY = NSEvent.mouseLocation.y   // global screen Y
+        self.dragStartPanelY = self.panel.frame.origin.y
       case .leftMouseDragged:
-        if let key = self.draggingKey, let start = self.dragStartX,
-           abs(event.locationInWindow.x - start) > 3 {
+        let dy = NSEvent.mouseLocation.y - (self.dragStartMouseY ?? NSEvent.mouseLocation.y)
+        let dx = event.locationInWindow.x - (self.dragStartX ?? event.locationInWindow.x)
+        // Vertical drag → SLIDE the whole bar up/down. Horizontal drag on a tile → reorder.
+        if self.barSlid || (abs(dy) > 4 && abs(dy) >= abs(dx)) {
+          self.barSlid = true
+          self.didDrag = true
+          self.slideBar(toPanelY: (self.dragStartPanelY ?? self.panel.frame.origin.y) + dy)
+        } else if let key = self.draggingKey, abs(dx) > 3 {
+          self.didDrag = true
           self.moveTile(key, toward: event.locationInWindow.x)
         }
       case .leftMouseUp:
+        if self.barSlid { self.persistBarOffset() }
         self.draggingKey = nil
         self.dragStartX = nil
+        self.dragStartMouseY = nil
+        self.dragStartPanelY = nil
+        // Keep didDrag true through the click that this mouse-up triggers (so the
+        // menu is suppressed after a drag), then clear it for the next gesture.
+        let dragged = self.didDrag
+        DispatchQueue.main.async { if dragged { self.didDrag = false } }
       default: break
       }
       return event
     }
   }
 
+  // Move the whole bar to a new vertical position, clamped on-screen.
+  private func slideBar(toPanelY y: CGFloat) {
+    guard let screen = panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { return }
+    let v = screen.visibleFrame
+    let barHeight: CGFloat = 38
+    let clampedY = min(max(v.minY, y), v.maxY - barHeight)
+    panel.setFrame(NSRect(x: v.minX, y: clampedY, width: v.width, height: barHeight), display: true)
+  }
+
+  // Persist the bar's current vertical position as an offset below the top.
+  private func persistBarOffset() {
+    guard let screen = panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { return }
+    let v = screen.visibleFrame
+    let offset = max(0, (v.maxY - 38) - panel.frame.origin.y)
+    UserDefaults.standard.set(Double(offset), forKey: "ticketBarYOffset")
+  }
+
   private func moveTile(_ key: String, toward windowX: CGFloat) {
     guard let source = tiles.firstIndex(where: { $0.key == key }) else { return }
     var target = source
@@ -194,17 +248,23 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     openItem.target = self
     menu.addItem(openItem)
     menu.addItem(.separator())
+    // autoenable OFF so the detail rows render in normal readable text instead of
+    // greyed-out/disabled — and each row is clickable (jumps to that ticket).
+    menu.autoenablesItems = false
     let details: [String]
-    if key == "counts" {
-      details = [counts.stringValue.replacingOccurrences(of: "   ", with: "  •  ")]
+    if key == "open" || key == "blocked" || key == "doing" {
+      let list = statusDetails[key] ?? []
+      details = list.isEmpty ? ["No \(key) tickets"] : list
     } else if key == "working" {
       details = workingDetails.isEmpty ? ["No tickets currently marked doing"] : workingDetails
     } else {
       details = [latestDetail]
     }
     for detail in details.prefix(12) {
-      let item = NSMenuItem(title: detail, action: nil, keyEquivalent: "")
-      item.isEnabled = false
+      let item = NSMenuItem(title: detail, action: #selector(openDetailTicket(_:)), keyEquivalent: "")
+      item.target = self
+      item.representedObject = detail
+      item.isEnabled = true
       menu.addItem(item)
     }
     menu.addItem(.separator())
@@ -219,6 +279,23 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
 
   @objc private func openControlFromMenu() { openTicketPopup() }
 
+  // Click a ticket detail row → open the control window filtered to that ticket.
+  @objc private func openDetailTicket(_ sender: NSMenuItem) {
+    let text = sender.representedObject as? String ?? ""
+    guard let range = text.range(of: "TK-\\d+", options: .regularExpression) else {
+      openTicketPopup(); return
+    }
+    let id = String(text[range])
+    openTicketPopup()
+    // Retry a few times so it lands whether the web view was just created or already loaded.
+    for delay in [0.2, 0.6, 1.2] {
+      DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+        guard let web = self?.ticketPopup?.contentView as? WKWebView else { return }
+        web.evaluateJavaScript("window.setQ ? (setQ('\(id)'), true) : (location.hash='q=\(id)')", completionHandler: nil)
+      }
+    }
+  }
+
   // Vertical bar placement — "ticketBarYOffset" is pixels BELOW the top of the
   // visible frame (0 = pinned under the menu bar, the original behavior).
   @objc private func moveBarUp(_ sender: NSMenuItem) { nudgeBar(by: -40) }
@@ -355,16 +432,20 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
           if type == "action" || type == "comment" || type == "status" { newest = event }
         }
         let active = states.values.filter { $0.status != "done" && $0.status != "stopped" }
-        let open = active.filter { $0.status == "open" }.count
-        let blocked = active.filter { $0.status == "blocked" }.count
+        let openList = active.filter { $0.status == "open" }.sorted { $0.updated > $1.updated }
+        let blockedList = active.filter { $0.status == "blocked" }.sorted { $0.updated > $1.updated }
         let doing = active.filter { $0.status == "doing" }.sorted { $0.updated > $1.updated }
         let workText = doing.prefix(3).map { "\(self.shortID($0.id)) \(self.fiveWords($0.title))" }.joined(separator: "  •  ")
         let latestText = self.describe(newest)
+        let row: (TicketState) -> String = { "\(self.shortID($0.id)) · \($0.title)" }
         DispatchQueue.main.async {
-          self.counts.stringValue = "Tickets  OPEN \(open)   BLOCKED \(blocked)   DOING \(doing.count)"
+          self.openLabel.stringValue    = "OPEN \(openList.count)"
+          self.blockedLabel.stringValue = "BLOCKED \(blockedList.count)"
+          self.doingLabel.stringValue   = "DOING \(doing.count)"
+          self.statusDetails = ["open": openList.map(row), "blocked": blockedList.map(row), "doing": doing.map(row)]
           self.working.stringValue = "Working · \(workText.isEmpty ? "none" : workText)"
           self.latest.stringValue = "Latest · \(latestText)"
-          self.workingDetails = doing.map { "\(self.shortID($0.id)) · \($0.title)" }
+          self.workingDetails = doing.map(row)
           self.latestDetail = latestText
           self.panel.orderFrontRegardless()
           if let popup = self.ticketPopup, !popup.isMiniaturized { popup.orderFrontRegardless() }

← 7db3a0af correct cycle evidence timestamp  ·  back to Ticket System  ·  Ticket desktop bar: add night/day theme toggle (🌙/☀️ button 61e600af →