Add the window identification overlay

Every profile launches the same Claude Desktop binary with the same icon
and title, so with two or more open there was no way to tell which window
belonged to which profile at a glance. WindowOverlay pins a small tag —
profile name, in that profile's own dot colour — to each running managed
profile's window, tracked live via AXObserver notifications rather than
polling. Only a persistent process can hold a watcher like that, which is
exactly the gap the old one-shot CLI could never close.

Shown for every running managed profile, not just when two or more are
up: the tag is an identity marker, not merely a disambiguator. Never
shown on "default", which isn't a shannoncoat profile at all.

Positioned top-right, since top-left is where a window's close/minimize/
zoom controls live. Draggable in case that still obstructs something in a
particular layout, on a .nonactivatingPanel so dragging it doesn't steal
focus from the window underneath, with the offset persisted.

Attachment retries for ~3s rather than requiring the target's first
window to already exist: a cold Electron launch can take a second or more
between the process starting — which is what triggers the update, via
NSWorkspace's launch notification — and its first window existing. Losing
that race previously meant the tag silently never appeared at all.

Also ignores CHECKPOINT.md, a local session-handoff note.
This commit is contained in:
Claude Opus 5
2026-08-05 11:40:56 +06:00
committed by bdeshi
parent 9673dc2792
commit 1d3a539edd
4 changed files with 264 additions and 1 deletions
+3
View File
@@ -4,6 +4,9 @@
icon/claude-src/
icon/AppIcon.iconset/
# Session handoff notes — local only, never committed
CHECKPOINT.md
# Local reference material (e.g. nested checkouts of other projects) — not
# part of this project, never meant to be committed.
.scratch/
+29
View File
@@ -9,6 +9,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
let liveState = LiveState()
private var currentInfos: [ProfileInfo] = []
private var collisionAlertShown = false
private var windowOverlays: [String: WindowOverlay] = [:]
private var pendingWindowOverlays: Set<String> = []
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
@@ -50,6 +52,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
currentInfos = infos
applyTitle(infos)
ManageWindowController.shared.update(infos)
updateWindowOverlays(infos)
}
// Shown on every running *managed* profile never on "default",
// which isn't a shannoncoat profile at all, just the real underlying
// Claude install. Removing an entry here deinits its WindowOverlay,
// which tears down its AXObserver and hides the tag panel.
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
let managed = infos.filter { $0.running && $0.profile.name != "default" }
let managedNames = Set(managed.map(\.profile.name))
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
pendingWindowOverlays.formIntersection(managedNames)
for info in managed
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
guard let pid = info.pid else { continue }
let name = info.profile.name
pendingWindowOverlays.insert(name)
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
guard let self else { return }
pendingWindowOverlays.remove(name)
// The profile may have quit again while this was retrying
// for its window don't attach a stale overlay if so.
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
windowOverlays[name] = overlay
}
}
}
// Two profiles sharing a dir is a fatal misconfiguration (would
+231
View File
@@ -0,0 +1,231 @@
// A small floating tag pinned to a Claude window's corner, showing which
// profile it belongs to every profile launches the same app with the
// same icon and title, so there's otherwise no way to tell two open
// Claude windows apart at a glance. Kept in sync live via AXObserver
// notifications (move/resize/miniaturize/destroy) rather than polling
// only a persistent process can hold a watcher like this at all, which is
// exactly the gap the old one-shot CLI script couldn't close.
import AppKit
import ApplicationServices
// The tag's position relative to its window's top-right corner, shared
// across every open tag and persisted so a drag survives a relaunch.
// Anchored from the right/top edges (not left) because that's the corner
// that stays put under the common resize gesture (dragging the bottom-right
// handle) an offset-from-left would drift away from a user's chosen spot
// every time the window changed width.
enum WindowOverlayPosition {
private static let rightInsetKey = "WindowOverlayRightInset"
private static let topInsetKey = "WindowOverlayTopInset"
// Top-right by default top-left is where every window's close/
// minimize/zoom controls live, which the tag must never sit over.
static let defaultRightInset: CGFloat = 10
static let defaultTopInset: CGFloat = 6
static var rightInset: CGFloat {
get { (UserDefaults.standard.object(forKey: rightInsetKey) as? Double).map { CGFloat($0) } ?? defaultRightInset }
set { UserDefaults.standard.set(Double(newValue), forKey: rightInsetKey) }
}
static var topInset: CGFloat {
get { (UserDefaults.standard.object(forKey: topInsetKey) as? Double).map { CGFloat($0) } ?? defaultTopInset }
set { UserDefaults.standard.set(Double(newValue), forKey: topInsetKey) }
}
}
final class WindowOverlay: NSObject, NSWindowDelegate {
let pid: pid_t
private let axWindow: AXUIElement
private var observer: AXObserver?
private let panel: NSPanel
private static let tagSize = NSSize(width: 110, height: 20)
init?(profileName: String, color: NSColor, pid: pid_t) {
let axApp = AXUIElementCreateApplication(pid)
guard let window = Self.firstWindow(of: axApp) else { return nil }
self.pid = pid
self.axWindow = window
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: Self.tagSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .floating
// Draggable (so it can be moved off whatever it's obstructing),
// but .nonactivatingPanel keeps a click/drag from stealing focus
// away from the Claude window underneath.
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.stationary, .ignoresCycle]
let background = NSView(frame: NSRect(origin: .zero, size: Self.tagSize))
background.wantsLayer = true
background.layer?.backgroundColor = color.cgColor
background.layer?.cornerRadius = 5
let label = NSTextField(labelWithString: profileName)
label.frame = NSRect(origin: .zero, size: Self.tagSize)
label.alignment = .center
label.font = .systemFont(ofSize: 11, weight: .semibold)
label.textColor = .white
label.drawsBackground = false
label.lineBreakMode = .byTruncatingTail
background.addSubview(label)
panel.contentView = background
self.panel = panel
super.init()
panel.delegate = self
guard reposition() else { return nil }
startObserving()
}
// A cold Electron launch can take a second or more between the
// process starting (which is what triggers `updateWindowOverlays`,
// via NSWorkspace's launch notification) and its first window
// actually existing the same race ClaudeControl.focus's
// waitForWindow already handles for the same reason. `init?` can't
// retry on its own (once it returns nil, that attempt is done), so
// this polls it every quarter second for up to ~3s before giving up.
static func attach(
profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12,
completion: @escaping (WindowOverlay?) -> Void
) {
if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) {
completion(overlay)
} else if attemptsRemaining > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
attach(
profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1,
completion: completion)
}
} else {
completion(nil)
}
}
deinit {
stopObserving()
panel.orderOut(nil)
}
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement], let first = windows.first
else { return nil }
return first
}
private static func frame(of window: AXUIElement) -> CGRect? {
var posValue: CFTypeRef?
var sizeValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success,
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success,
CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID()
else { return nil }
var point = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point),
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
else { return nil }
return CGRect(origin: point, size: size)
}
private static func screen(containing axFrame: CGRect) -> NSScreen? {
NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main
}
// AX coordinates are top-left-origin (screen top = y 0); AppKit screen
// coordinates are bottom-left-origin. Returns the window's frame
// translated into AppKit's space.
private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect {
NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height,
width: axFrame.width, height: axFrame.height)
}
// Positions the tag using the persisted (or default) offset from the
// window's top-right corner. Returns false (and hides the tag) if the
// window has no readable frame right now minimized, or Accessibility
// not granted.
@discardableResult
private func reposition() -> Bool {
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else {
panel.orderOut(nil)
return false
}
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
let origin = NSPoint(
x: windowFrame.maxX - WindowOverlayPosition.rightInset - Self.tagSize.width,
y: windowFrame.maxY - WindowOverlayPosition.topInset - Self.tagSize.height)
panel.setFrameOrigin(origin)
if !panel.isVisible { panel.orderFrontRegardless() }
return true
}
// Fires for every panel move ours (from `reposition`, when the
// tracked window itself moves/resizes) and the user's (dragging the
// tag). Either way, re-deriving the offset from the panel's actual
// current position and persisting it is correct: our own moves just
// recompute the same offset they were placed with, a no-op; a user
// drag captures the new spot they chose so it survives the next
// reposition and the next launch. Clamped to stay within the window
// so a sloppy drag can't wander off it entirely.
func windowDidMove(_ notification: Notification) {
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return }
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
let panelOrigin = panel.frame.origin
let rightInset = (windowFrame.maxX - (panelOrigin.x + Self.tagSize.width))
.clamped(to: 0...(max(0, windowFrame.width - Self.tagSize.width)))
let topInset = (windowFrame.maxY - (panelOrigin.y + Self.tagSize.height))
.clamped(to: 0...(max(0, windowFrame.height - Self.tagSize.height)))
WindowOverlayPosition.rightInset = rightInset
WindowOverlayPosition.topInset = topInset
}
// MARK: - Live tracking
private func startObserving() {
var newObserver: AXObserver?
guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver
else { return }
observer = newObserver
let refcon = Unmanaged.passUnretained(self).toOpaque()
for name in [
kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification,
kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification,
] {
AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon)
}
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode)
}
private func stopObserving() {
guard let observer else { return }
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
self.observer = nil
}
private static let axCallback: AXObserverCallback = { _, _, notification, refcon in
guard let refcon else { return }
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
switch notification as String {
case kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification:
overlay.panel.orderOut(nil)
default:
overlay.reposition()
}
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.3
0.0.5