← 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
M desktop-bar/TicketBar.swift
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 →