← back to Ticket System
Add persistent desktop ticket bar
38ca520f3eb0dd8514fd5f14a33d52cd0030776e · 2026-09-02 14:22:49 -0700 · Steve Abrams
Files touched
M .gitignoreA desktop-bar/README.mdA desktop-bar/TicketBar.swiftA desktop-bar/build.shA desktop-bar/com.steve.ticket-desktop-bar.plist
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 →