[object Object]

← back to Ticket System

Add persistent desktop ticket bar

38ca520f3eb0dd8514fd5f14a33d52cd0030776e · 2026-09-02 14:22:49 -0700 · Steve Abrams

Files touched

Diff

commit 38ca520f3eb0dd8514fd5f14a33d52cd0030776e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 14:22:49 2026 -0700

    Add persistent desktop ticket bar
---
 .gitignore                                     |   1 +
 desktop-bar/README.md                          |   7 ++
 desktop-bar/TicketBar.swift                    | 141 +++++++++++++++++++++++++
 desktop-bar/build.sh                           |   6 ++
 desktop-bar/com.steve.ticket-desktop-bar.plist |  11 ++
 5 files changed, 166 insertions(+)

diff --git a/.gitignore b/.gitignore
index 772d0548..a0a513c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ tmp/
 dist/
 build/
 .next/
+desktop-bar/bin/
diff --git a/desktop-bar/README.md b/desktop-bar/README.md
new file mode 100644
index 00000000..c066e341
--- /dev/null
+++ b/desktop-bar/README.md
@@ -0,0 +1,7 @@
+# Ticket Desktop Bar
+
+Native read-only macOS bar showing exact ticket counts, recently active `doing`
+tickets, and the newest ledger event. It sits below the menu bar across Spaces;
+click **Open Fleet** to open the full ticket viewer.
+
+Build with `./build.sh`. The launch agent uses the compiled binary in `bin/`.
diff --git a/desktop-bar/TicketBar.swift b/desktop-bar/TicketBar.swift
new file mode 100644
index 00000000..94cbc446
--- /dev/null
+++ b/desktop-bar/TicketBar.swift
@@ -0,0 +1,141 @@
+import AppKit
+
+private let ledgerURL = FileManager.default.homeDirectoryForCurrentUser
+  .appendingPathComponent(".claude/tickets/events.jsonl")
+private let viewerURL = URL(string: "http://127.0.0.1:9794/?section=all&layout=grid")!
+
+private struct TicketState {
+  var id = ""
+  var title = ""
+  var status = "open"
+  var updated = ""
+}
+
+private final class BarController: NSObject, NSApplicationDelegate {
+  private var panel: NSPanel!
+  private let counts = NSTextField(labelWithString: "Tickets · loading…")
+  private let working = NSTextField(labelWithString: "Working · —")
+  private let latest = NSTextField(labelWithString: "Latest · —")
+  private var timer: Timer?
+
+  func applicationDidFinishLaunching(_ notification: Notification) {
+    NSApp.setActivationPolicy(.accessory)
+    buildPanel()
+    refresh()
+    timer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { [weak self] _ in self?.refresh() }
+    NotificationCenter.default.addObserver(self, selector: #selector(reposition), name: NSApplication.didChangeScreenParametersNotification, object: nil)
+  }
+
+  private func buildPanel() {
+    panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
+    panel.level = .statusBar
+    panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
+    panel.isOpaque = false
+    panel.backgroundColor = NSColor(calibratedWhite: 0.055, alpha: 0.97)
+    panel.hasShadow = true
+    panel.hidesOnDeactivate = false
+
+    let open = NSButton(title: "Open Fleet ↗", target: self, action: #selector(openViewer))
+    open.bezelStyle = .inline
+    open.contentTintColor = .white
+    for label in [counts, working, latest] {
+      label.textColor = .white
+      label.font = .systemFont(ofSize: 12, weight: label === counts ? .bold : .medium)
+      label.lineBreakMode = .byTruncatingTail
+      label.maximumNumberOfLines = 1
+    }
+    working.textColor = NSColor(calibratedRed: 0.45, green: 0.78, blue: 1, alpha: 1)
+    latest.textColor = NSColor(calibratedWhite: 0.72, alpha: 1)
+
+    let stack = NSStackView(views: [counts, divider(), working, divider(), latest, open])
+    stack.orientation = .horizontal
+    stack.spacing = 12
+    stack.alignment = .centerY
+    stack.edgeInsets = NSEdgeInsets(top: 0, left: 14, bottom: 0, right: 10)
+    counts.setContentHuggingPriority(.required, for: .horizontal)
+    working.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+    latest.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+    open.setContentHuggingPriority(.required, for: .horizontal)
+    panel.contentView = stack
+    reposition()
+    panel.orderFrontRegardless()
+  }
+
+  private func divider() -> NSView {
+    let view = NSView(frame: NSRect(x: 0, y: 0, width: 1, height: 18))
+    view.wantsLayer = true
+    view.layer?.backgroundColor = NSColor(calibratedWhite: 0.28, alpha: 1).cgColor
+    view.widthAnchor.constraint(equalToConstant: 1).isActive = true
+    return view
+  }
+
+  @objc private func reposition() {
+    guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
+    let frame = screen.visibleFrame
+    panel.setFrame(NSRect(x: frame.minX, y: frame.maxY - 38, width: frame.width, height: 38), display: true)
+  }
+
+  @objc private func openViewer() { NSWorkspace.shared.open(viewerURL) }
+
+  private func refresh() {
+    DispatchQueue.global(qos: .utility).async { [weak self] in
+      guard let self else { return }
+      do {
+        let text = try String(contentsOf: ledgerURL, encoding: .utf8)
+        var states: [String: TicketState] = [:]
+        var newest: [String: Any]?
+        for line in text.split(separator: "\n") {
+          guard let data = line.data(using: .utf8),
+                let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+                let id = event["id"] as? String, id.hasPrefix("TK-") else { continue }
+          let type = event["type"] as? String ?? ""
+          let timestamp = event["ts"] as? String ?? ""
+          if type == "create" {
+            states[id] = TicketState(id: id, title: event["title"] as? String ?? id, status: "open", updated: timestamp)
+          } else if var ticket = states[id] {
+            if type == "status", let status = event["status"] as? String { ticket.status = status }
+            ticket.updated = timestamp
+            states[id] = ticket
+          }
+          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 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)
+        DispatchQueue.main.async {
+          self.counts.stringValue = "Tickets  OPEN \(open)   BLOCKED \(blocked)   DOING \(doing.count)"
+          self.working.stringValue = "Working · \(workText.isEmpty ? "none" : workText)"
+          self.latest.stringValue = "Latest · \(latestText)"
+          self.panel.orderFrontRegardless()
+        }
+      } catch {
+        DispatchQueue.main.async { self.latest.stringValue = "Latest · ledger unavailable" }
+      }
+    }
+  }
+
+  private func shortID(_ id: String) -> String {
+    let parts = id.split(separator: "-")
+    return parts.count > 1 ? "TK-\(parts[1])" : id
+  }
+
+  private func fiveWords(_ text: String) -> String {
+    text.split(whereSeparator: { $0.isWhitespace }).prefix(5).joined(separator: " ")
+  }
+
+  private func describe(_ event: [String: Any]?) -> String {
+    guard let event else { return "no activity" }
+    let id = shortID(event["id"] as? String ?? "")
+    let type = event["type"] as? String ?? "event"
+    let detail = (event["text"] as? String) ?? (event["status"] as? String) ?? "updated"
+    return "\(id) \(type) · \(fiveWords(detail))"
+  }
+}
+
+let app = NSApplication.shared
+private let delegate = BarController()
+app.delegate = delegate
+app.run()
diff --git a/desktop-bar/build.sh b/desktop-bar/build.sh
new file mode 100755
index 00000000..82a3ec02
--- /dev/null
+++ b/desktop-bar/build.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+mkdir -p "$ROOT/bin"
+xcrun swiftc "$ROOT/TicketBar.swift" -o "$ROOT/bin/ticket-desktop-bar" -framework AppKit
+echo "$ROOT/bin/ticket-desktop-bar"
diff --git a/desktop-bar/com.steve.ticket-desktop-bar.plist b/desktop-bar/com.steve.ticket-desktop-bar.plist
new file mode 100644
index 00000000..4195b7f5
--- /dev/null
+++ b/desktop-bar/com.steve.ticket-desktop-bar.plist
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.ticket-desktop-bar</string>
+  <key>ProgramArguments</key><array><string>/Users/macstudio3/Projects/ticket-system/desktop-bar/bin/ticket-desktop-bar</string></array>
+  <key>RunAtLoad</key><true/>
+  <key>KeepAlive</key><true/>
+  <key>ProcessType</key><string>Interactive</string>
+  <key>StandardOutPath</key><string>/tmp/ticket-desktop-bar.out.log</string>
+  <key>StandardErrorPath</key><string>/tmp/ticket-desktop-bar.err.log</string>
+</dict></plist>

← ca25bd19 Use shared path for runner override  ·  back to Ticket System  ·  Record desktop ticket bar proof c73300b3 →