From 2ac50a628d6f18ead04e0f5c296d632b9e300723 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 28 May 2026 17:33:52 -0700 Subject: [PATCH 01/14] feat(hub): add current project selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add selectedProject / effectiveProject to AstationHubManager. Auto- selects first project on load; reconciles on refresh. Voice coding, ConvoAI agent, and token generation all use effectiveProject instead of projects.first. Projects window shows checkmark on selected project; clicking a row changes it. Menubar shows current project name. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 20 +++++++++- .../Menubar/ProjectsWindowController.swift | 15 +++++++- Sources/Menubar/StatusBarController.swift | 37 ++++++++++++++----- Sources/Menubar/VoiceCodingManager.swift | 7 ++-- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 58a9176..fe49b8d 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -6,6 +6,7 @@ import Network class AstationHubManager: ObservableObject { @Published var connectedClients: [ConnectedClient] = [] @Published var projects: [AgoraProject] = [] + @Published var selectedProject: AgoraProject? @Published var isClaudeRunning = false @Published var startTime = Date() @Published var voiceActive = false @@ -278,6 +279,9 @@ class AstationHubManager: ObservableObject { await MainActor.run { self.projects = fetched self.projectLoadError = nil + if self.selectedProject == nil || !fetched.contains(where: { $0.id == self.selectedProject?.id }) { + self.selectedProject = fetched.first + } Log.info(" Loaded \(fetched.count) projects from BFF") } } catch AgoraAPIError.unauthorized { @@ -305,6 +309,20 @@ class AstationHubManager: ObservableObject { func getProjects() -> [AgoraProject] { return projects } + + /// The project to use for RTC, ConvoAI, and token generation. + var effectiveProject: AgoraProject? { + selectedProject ?? projects.first + } + + func selectProject(id: String) { + guard let project = projects.first(where: { $0.id == id }) else { + Log.warn("[AstationHub] selectProject: no project with id \(id)") + return + } + selectedProject = project + Log.info("[AstationHub] Selected project: \(project.name)") + } // MARK: - Token Management @@ -322,7 +340,7 @@ class AstationHubManager: ObservableObject { if let projectId = projectId { project = projects.first(where: { $0.id == projectId || $0.vendorKey == projectId }) } else { - project = projects.first + project = effectiveProject } guard let project = project, !project.signKey.isEmpty else { diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 0a0b583..10ceb79 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -65,6 +65,8 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let table = NSTableView() table.rowHeight = 28 table.usesAlternatingRowBackgroundColors = true + table.allowsEmptySelection = true + table.allowsMultipleSelection = false table.delegate = self table.dataSource = self @@ -184,6 +186,15 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { return hubManager.getProjects().count } + func tableViewSelectionDidChange(_ notification: Notification) { + let row = tableView.selectedRow + guard row >= 0 else { return } + let projects = hubManager.getProjects() + guard row < projects.count else { return } + hubManager.selectProject(id: projects[row].id) + tableView.reloadData() + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { @@ -193,7 +204,9 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { switch colId { case nameColId: - return makeLabelCell(tableView, id: "nameCell", text: project.name) + let isSelected = hubManager.selectedProject?.id == project.id + let displayName = isSelected ? "\u{2713} \(project.name)" : " \(project.name)" + return makeLabelCell(tableView, id: "nameCell", text: displayName) case appIdColId: return makeTextWithCopyCell( diff --git a/Sources/Menubar/StatusBarController.swift b/Sources/Menubar/StatusBarController.swift index fd5c6fd..1881aec 100644 --- a/Sources/Menubar/StatusBarController.swift +++ b/Sources/Menubar/StatusBarController.swift @@ -11,6 +11,7 @@ class StatusBarController: NSObject, NSMenuDelegate { private lazy var projectsWindowController = ProjectsWindowController(hubManager: hubManager) private lazy var joinChannelWindowController = JoinChannelWindowController(hubManager: hubManager) private lazy var connectionsWindowController = ConnectionsWindowController(hubManager: hubManager) + var hotkeyManager: HotkeyManager? private var headerTapCount = 0 private var lastHeaderTapTime: Date? @@ -71,8 +72,14 @@ class StatusBarController: NSObject, NSMenuDelegate { statusItem.isEnabled = false statusMenu.addItem(statusItem) + let projectTitle: String + if let selected = hubManager.selectedProject { + projectTitle = "📋 Project: \(selected.name)" + } else { + projectTitle = "📋 Projects: \(systemStatus.projects) loaded" + } let projectsItem = NSMenuItem( - title: "📋 Projects: \(systemStatus.projects) loaded", + title: projectTitle, action: nil, keyEquivalent: "" ) @@ -108,17 +115,21 @@ class StatusBarController: NSObject, NSMenuDelegate { voiceOffItem.isEnabled = false statusMenu.addItem(voiceOffItem) - if hubManager.rtcManager.isInChannel { - let handsFreeItem = NSMenuItem( - title: "Start Hands-Free Mode", - action: #selector(startHandsFreeMode), - keyEquivalent: "" - ) - handsFreeItem.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Hands-Free") - handsFreeItem.target = self - statusMenu.addItem(handsFreeItem) + if hotkeyManager?.voiceHotkeyFailed == true { + let warn = NSMenuItem(title: " ⚠ Ctrl+V hotkey unavailable (conflict)", action: nil, keyEquivalent: "") + warn.isEnabled = false + statusMenu.addItem(warn) } + let handsFreeItem = NSMenuItem( + title: "Start Hands-Free Mode", + action: #selector(startHandsFreeMode), + keyEquivalent: "" + ) + handsFreeItem.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Hands-Free") + handsFreeItem.target = self + statusMenu.addItem(handsFreeItem) + case .ptt: let pttItem = NSMenuItem( title: vcm.isWaitingForResponse ? "Voice (PTT): Waiting for Claude..." : "Voice (PTT): Active", @@ -160,6 +171,12 @@ class StatusBarController: NSObject, NSMenuDelegate { videoItem.target = self statusMenu.addItem(videoItem) + if hotkeyManager?.videoHotkeyFailed == true { + let warn = NSMenuItem(title: " ⚠ Ctrl+Shift+V hotkey unavailable (conflict)", action: nil, keyEquivalent: "") + warn.isEnabled = false + statusMenu.addItem(warn) + } + statusMenu.addItem(NSMenuItem.separator()) // Connected Atems Section diff --git a/Sources/Menubar/VoiceCodingManager.swift b/Sources/Menubar/VoiceCodingManager.swift index 6695f54..67876f3 100644 --- a/Sources/Menubar/VoiceCodingManager.swift +++ b/Sources/Menubar/VoiceCodingManager.swift @@ -126,8 +126,7 @@ class VoiceCodingManager: NSObject { return } - let projects = hubManager.getProjects() - guard let project = projects.first else { + guard let project = hubManager.effectiveProject else { updateStage("Voice: No projects configured", autoHideAfter: 2.0) finishRtcJoin(success: false) return @@ -534,7 +533,7 @@ class VoiceCodingManager: NSObject { // MARK: - ConvoAI Agent private func createConvoAIAgent(sessionId: String) { - guard let project = hubManager.projects.first, + guard let project = hubManager.effectiveProject, let channel = hubManager.rtcManager.currentChannel, !project.vendorKey.isEmpty, !project.signKey.isEmpty @@ -582,7 +581,7 @@ class VoiceCodingManager: NSObject { private func stopConvoAIAgent() { guard let agentId = activeAgentId, - let project = hubManager.projects.first, + let project = hubManager.effectiveProject, let channel = hubManager.rtcManager.currentChannel, !project.vendorKey.isEmpty, !project.signKey.isEmpty From ba20d5018bade361c222b5dc7394e55de31c27a8 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 28 May 2026 17:34:00 -0700 Subject: [PATCH 02/14] feat(hotkey): show warning when hotkey registration fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track voiceHotkeyFailed / videoHotkeyFailed in HotkeyManager. Display warning items in the menubar status menu when registration fails. Replace print() with Log calls. Also remove isInChannel guard on Start Hands-Free Mode menu item — startHandsFree() already auto-joins RTC internally. 🤖 Built with SMT --- Sources/Menubar/AstationApp.swift | 1 + Sources/Menubar/HotkeyManager.swift | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index c69e409..9e8bf42 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -97,6 +97,7 @@ class AstationApp: NSObject, NSApplicationDelegate { self?.statusBarController.showStatus() } hotkeyManager?.registerHotkeys() + statusBarController.hotkeyManager = hotkeyManager // Connect to relay using this Astation's identity, so Atem TUI can auto-reconnect // after the first `atem pair` without needing to pair again. diff --git a/Sources/Menubar/HotkeyManager.swift b/Sources/Menubar/HotkeyManager.swift index 4cb2bf3..74a7e9a 100644 --- a/Sources/Menubar/HotkeyManager.swift +++ b/Sources/Menubar/HotkeyManager.swift @@ -16,6 +16,9 @@ class HotkeyManager { /// Called when Ctrl+Shift+V is pressed (video toggle). var onVideoToggle: (() -> Void)? + private(set) var voiceHotkeyFailed = false + private(set) var videoHotkeyFailed = false + private static let voiceHotkeyID = UInt32(1) private static let videoHotkeyID = UInt32(2) private static let hotkeySignature = OSType(0x4154454D) // "ATEM" in ASCII @@ -70,7 +73,8 @@ class HotkeyManager { &voiceHotkeyRef ) if voiceResult != noErr { - print("[HotkeyManager] Failed to register Ctrl+V: \(voiceResult)") + voiceHotkeyFailed = true + Log.warn("[HotkeyManager] Failed to register Ctrl+V hotkey: \(voiceResult)") } // Ctrl+Shift+V → video toggle @@ -87,10 +91,11 @@ class HotkeyManager { &videoHotkeyRef ) if videoResult != noErr { - print("[HotkeyManager] Failed to register Ctrl+Shift+V: \(videoResult)") + videoHotkeyFailed = true + Log.warn("[HotkeyManager] Failed to register Ctrl+Shift+V hotkey: \(videoResult)") } - print("[HotkeyManager] Global hotkeys registered: Ctrl+V (voice), Ctrl+Shift+V (video)") + Log.info("[HotkeyManager] Global hotkeys registered: Ctrl+V (voice), Ctrl+Shift+V (video)") } /// Unregister all hotkeys. @@ -103,7 +108,7 @@ class HotkeyManager { UnregisterEventHotKey(ref) videoHotkeyRef = nil } - print("[HotkeyManager] Hotkeys unregistered") + Log.info("[HotkeyManager] Hotkeys unregistered") } // MARK: - Carbon Event Handler From e787eb835f0b3fc2502f1333aba5cf2ae1d9db9e Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 28 May 2026 21:09:38 -0700 Subject: [PATCH 03/14] fix(hub): show friendly message on expired session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When token refresh fails (e.g. refresh token expired after days), show "Session expired — please sign in again" instead of the raw HTTP 400 error. Post .credentialsChanged to update the UI. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index fe49b8d..de88de1 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -265,11 +265,19 @@ class AstationHubManager: ObservableObject { Task { let token: String do { token = try await tokenProvider.validToken() } - catch { + catch SsoError.notSignedIn { await MainActor.run { self.projects = [] - self.projectLoadError = error.localizedDescription - Log.info("[AstationHub] Cannot load projects: \(error.localizedDescription)") + self.projectLoadError = "Not signed in. Open Settings → Sign in with Agora." + Log.info("[AstationHub] Cannot load projects: not signed in") + } + return + } catch { + await MainActor.run { + self.projects = [] + self.projectLoadError = "Session expired — please sign in again." + NotificationCenter.default.post(name: .credentialsChanged, object: nil) + Log.info("[AstationHub] Session expired, cleared: \(error.localizedDescription)") } return } From 5d4d171e9563a7a7b68fe07c795849543ca6ecab Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 00:21:45 -0700 Subject: [PATCH 04/14] feat(rtc): default encryption OFF, randomize channel names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change RTCEncryptionMode.manualJoinDefault from .aes256Gcm2 to .none and move "None" to top of the picker list. - Voice coding channel: "astation-<8-hex-random>" instead of "astation-default". Manual join UI gets the same pattern. 🤖 Built with SMT --- Sources/Menubar/JoinChannelWindowController.swift | 9 +++++++-- Sources/Menubar/RTCJoinOptions.swift | 4 ++-- Sources/Menubar/VoiceCodingManager.swift | 6 +++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/Menubar/JoinChannelWindowController.swift b/Sources/Menubar/JoinChannelWindowController.swift index 5dd135a..c4ee46f 100644 --- a/Sources/Menubar/JoinChannelWindowController.swift +++ b/Sources/Menubar/JoinChannelWindowController.swift @@ -64,8 +64,9 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { contentView.addSubview(channelLabel) channelField = NSTextField(frame: NSRect(x: 110, y: y, width: fieldWidth, height: 22)) - channelField.placeholderString = "e.g. astation-default" - channelField.stringValue = "astation-default" + let defaultChannel = "astation-\(Self.randomHex(8))" + channelField.placeholderString = "e.g. astation-a1b2c3d4" + channelField.stringValue = defaultChannel contentView.addSubview(channelField) y -= 40 @@ -338,6 +339,10 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { ) } + private static func randomHex(_ length: Int) -> String { + (0.. String { + (0.. Date: Fri, 29 May 2026 00:25:08 -0700 Subject: [PATCH 05/14] fix(ui): pre-select current project in Join RTC Channel picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- Sources/Menubar/JoinChannelWindowController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Menubar/JoinChannelWindowController.swift b/Sources/Menubar/JoinChannelWindowController.swift index c4ee46f..1428d2a 100644 --- a/Sources/Menubar/JoinChannelWindowController.swift +++ b/Sources/Menubar/JoinChannelWindowController.swift @@ -220,6 +220,10 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { joinButton?.isEnabled = false } else { projectPicker?.addItems(withTitles: projects.map { $0.name }) + if let selected = hubManager.selectedProject, + let idx = projects.firstIndex(where: { $0.id == selected.id }) { + projectPicker?.selectItem(at: idx) + } projectPicker?.isEnabled = true joinButton?.isEnabled = true } From d8cbaafae811bce2c32918911da6a58a98250303 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 00:28:45 -0700 Subject: [PATCH 06/14] feat(ui): double-click to select project with fade-out toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change project selection from single-click to double-click. Show a green "Selected: " toast next to the "Projects" header that fades out over 4 seconds. 🤖 Built with SMT --- .../Menubar/ProjectsWindowController.swift | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 10ceb79..8cde012 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -6,6 +6,7 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { private let hubManager: AstationHubManager private var tableView: NSTableView! private var statusLabel: NSTextField! + private var selectionToast: NSTextField! private var certificateVisibility: [String: Bool] = [:] private let nameColId = NSUserInterfaceItemIdentifier("name") @@ -44,9 +45,16 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { // Header let headerLabel = NSTextField(labelWithString: "Projects") headerLabel.font = NSFont.boldSystemFont(ofSize: 14) - headerLabel.frame = NSRect(x: 16, y: 410, width: 200, height: 24) + headerLabel.frame = NSRect(x: 16, y: 410, width: 80, height: 24) contentView.addSubview(headerLabel) + selectionToast = NSTextField(labelWithString: "") + selectionToast.font = NSFont.systemFont(ofSize: 12) + selectionToast.textColor = .systemGreen + selectionToast.alphaValue = 0 + selectionToast.frame = NSRect(x: 96, y: 410, width: 480, height: 24) + contentView.addSubview(selectionToast) + // Refresh button let refreshButton = NSButton( title: "Refresh", target: self, action: #selector(refreshProjects)) @@ -67,6 +75,8 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { table.usesAlternatingRowBackgroundColors = true table.allowsEmptySelection = true table.allowsMultipleSelection = false + table.doubleAction = #selector(tableDoubleClicked) + table.target = self table.delegate = self table.dataSource = self @@ -186,13 +196,24 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { return hubManager.getProjects().count } - func tableViewSelectionDidChange(_ notification: Notification) { - let row = tableView.selectedRow + @objc private func tableDoubleClicked() { + let row = tableView.clickedRow guard row >= 0 else { return } let projects = hubManager.getProjects() guard row < projects.count else { return } hubManager.selectProject(id: projects[row].id) tableView.reloadData() + showSelectionToast("Selected: \(projects[row].name)") + } + + private func showSelectionToast(_ text: String) { + selectionToast.stringValue = text + selectionToast.alphaValue = 1 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 4.0 + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + selectionToast.animator().alphaValue = 0 + } } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) From c095295e910da4e0f49a213222fafc5b74d54c3e Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 00:36:54 -0700 Subject: [PATCH 07/14] fix(ui): blue toast, reliable animation, vertically centered names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toast text color: green → blue - Cancel in-flight animation before showing new toast so it always appears on rapid double-clicks - Project name column uses NSTableCellView with centerY constraint for vertical centering 🤖 Built with SMT --- .../Menubar/ProjectsWindowController.swift | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 8cde012..c4cbb31 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -50,7 +50,7 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { selectionToast = NSTextField(labelWithString: "") selectionToast.font = NSFont.systemFont(ofSize: 12) - selectionToast.textColor = .systemGreen + selectionToast.textColor = .systemBlue selectionToast.alphaValue = 0 selectionToast.frame = NSRect(x: 96, y: 410, width: 480, height: 24) contentView.addSubview(selectionToast) @@ -207,12 +207,20 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { } private func showSelectionToast(_ text: String) { - selectionToast.stringValue = text - selectionToast.alphaValue = 1 + // Cancel any in-flight animation so the new toast appears immediately. NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 4.0 - ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) - selectionToast.animator().alphaValue = 0 + ctx.duration = 0 + ctx.allowsImplicitAnimation = false + selectionToast.animator().alphaValue = 1 + } completionHandler: { [weak self] in + guard let self else { return } + self.selectionToast.stringValue = text + self.selectionToast.alphaValue = 1 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 4.0 + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + self.selectionToast.animator().alphaValue = 0 + } } } @@ -243,8 +251,8 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { case statusColId: let cell = makeLabelCell(tableView, id: "statusCell", text: project.status) - if let textField = cell as? NSTextField { - textField.textColor = project.status == "active" ? .systemGreen : .secondaryLabelColor + if let cellView = cell as? NSTableCellView { + cellView.textField?.textColor = project.status == "active" ? .systemGreen : .secondaryLabelColor } return cell @@ -257,15 +265,24 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { private func makeLabelCell(_ tableView: NSTableView, id: String, text: String) -> NSView { let cellId = NSUserInterfaceItemIdentifier(id) - if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField { - existing.stringValue = text + if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView { + existing.textField?.stringValue = text return existing } + let cell = NSTableCellView() + cell.identifier = cellId let label = NSTextField(labelWithString: text) - label.identifier = cellId label.font = NSFont.systemFont(ofSize: 12) label.lineBreakMode = .byTruncatingTail - return label + label.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(label) + cell.textField = label + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -2), + label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell } private func makeTextWithCopyCell( From fbe9f977af39b5109d1820ac5ed5b702430063c0 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 00:50:33 -0700 Subject: [PATCH 08/14] fix(ui): align toast right after Projects header, same baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use sizeToFit on header to get actual width, position toast at headerLabel.maxX + 8 with same y origin and height. 🤖 Built with SMT --- Sources/Menubar/ProjectsWindowController.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index c4cbb31..e5393ff 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -45,14 +45,16 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { // Header let headerLabel = NSTextField(labelWithString: "Projects") headerLabel.font = NSFont.boldSystemFont(ofSize: 14) - headerLabel.frame = NSRect(x: 16, y: 410, width: 80, height: 24) + headerLabel.sizeToFit() + headerLabel.frame.origin = NSPoint(x: 16, y: 410) contentView.addSubview(headerLabel) selectionToast = NSTextField(labelWithString: "") selectionToast.font = NSFont.systemFont(ofSize: 12) selectionToast.textColor = .systemBlue selectionToast.alphaValue = 0 - selectionToast.frame = NSRect(x: 96, y: 410, width: 480, height: 24) + let toastX = headerLabel.frame.maxX + 8 + selectionToast.frame = NSRect(x: toastX, y: headerLabel.frame.origin.y, width: 480, height: headerLabel.frame.height) contentView.addSubview(selectionToast) // Refresh button From c5ceec9f01b2dca36d318323abc60223367a87f9 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 00:55:02 -0700 Subject: [PATCH 09/14] feat(ui): mask App ID and Certificate with ellipsis in the middle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display first 6 + "..." + last 6 chars (~15 chars) for both App ID and Certificate columns. Copy button still copies the full value. Narrowed column widths to match. 🤖 Built with SMT --- Sources/Menubar/ProjectsWindowController.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index e5393ff..cefa5cc 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -90,13 +90,13 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let appIdCol = NSTableColumn(identifier: appIdColId) appIdCol.title = "App ID" - appIdCol.width = 220 - appIdCol.minWidth = 120 + appIdCol.width = 160 + appIdCol.minWidth = 100 table.addTableColumn(appIdCol) let certCol = NSTableColumn(identifier: certColId) certCol.title = "Certificate" - certCol.width = 240 + certCol.width = 180 certCol.minWidth = 140 table.addTableColumn(certCol) @@ -241,12 +241,12 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { case appIdColId: return makeTextWithCopyCell( - tableView, id: "appIdCell", text: project.vendorKey, row: row, + tableView, id: "appIdCell", text: Self.masked(project.vendorKey), row: row, copyAction: #selector(copyAppId(_:))) case certColId: let isVisible = certificateVisibility[project.vendorKey] ?? false - let displayText = isVisible ? project.signKey : String(repeating: "\u{2022}", count: 12) + let displayText = isVisible ? Self.masked(project.signKey) : String(repeating: "\u{2022}", count: 12) let toggleTitle = isVisible ? "Hide" : "Show" return makeCertCell( tableView, id: "certCell", text: displayText, toggleTitle: toggleTitle, row: row) @@ -263,6 +263,12 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { } } + /// Show first 6 + "..." + last 6 chars. Short strings pass through unchanged. + private static func masked(_ s: String) -> String { + guard s.count > 16 else { return s } + return "\(s.prefix(6))...\(s.suffix(6))" + } + // MARK: - Cell Factories private func makeLabelCell(_ tableView: NSTableView, id: String, text: String) -> NSView { From d01b830ef79fc0a7fd289a1160899f87460b904c Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 01:01:55 -0700 Subject: [PATCH 10/14] fix(ui): remove trailing ellipsis on masked fields, add Created column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change lineBreakMode from byTruncatingTail to byClipping on App ID and Certificate cells so the masked text (first6...last6) shows cleanly without extra system ellipsis. - Add "Created" column showing yyyy-MM-dd HH:mm from the project's unix timestamp. 🤖 Built with SMT --- .../Menubar/ProjectsWindowController.swift | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index cefa5cc..1fec000 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -13,6 +13,7 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { private let appIdColId = NSUserInterfaceItemIdentifier("appId") private let certColId = NSUserInterfaceItemIdentifier("cert") private let statusColId = NSUserInterfaceItemIdentifier("status") + private let createdColId = NSUserInterfaceItemIdentifier("created") init(hubManager: AstationHubManager) { self.hubManager = hubManager @@ -102,10 +103,16 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let statusCol = NSTableColumn(identifier: statusColId) statusCol.title = "Status" - statusCol.width = 70 + statusCol.width = 60 statusCol.minWidth = 50 table.addTableColumn(statusCol) + let createdCol = NSTableColumn(identifier: createdColId) + createdCol.title = "Created" + createdCol.width = 130 + createdCol.minWidth = 90 + table.addTableColumn(createdCol) + scrollView.documentView = table contentView.addSubview(scrollView) self.tableView = table @@ -258,6 +265,10 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { } return cell + case createdColId: + let dateStr = Self.formatDate(project.created) + return makeLabelCell(tableView, id: "createdCell", text: dateStr) + default: return nil } @@ -269,6 +280,17 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { return "\(s.prefix(6))...\(s.suffix(6))" } + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + return f + }() + + private static func formatDate(_ unix: UInt64) -> String { + guard unix > 0 else { return "—" } + return dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(unix))) + } + // MARK: - Cell Factories private func makeLabelCell(_ tableView: NSTableView, id: String, text: String) -> NSView { @@ -300,7 +322,7 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { let label = NSTextField(labelWithString: text) label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byClipping label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) @@ -330,7 +352,7 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { let label = NSTextField(labelWithString: text) label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byClipping label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) From 79e8f485cd7a04efc34ce7438b51c8d798c9f7b5 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 01:04:59 -0700 Subject: [PATCH 11/14] fix(ui): baseline-align toast with Projects header, add seconds to date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use autolayout lastBaselineAnchor to align the blue toast text with the "Projects" header regardless of font size difference. Bump toast font to 13pt for better visual weight. Date format now includes seconds (yyyy-MM-dd HH:mm:ss). 🤖 Built with SMT --- .../Menubar/ProjectsWindowController.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 1fec000..188ede3 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -43,21 +43,26 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let contentView = NSView(frame: window.contentView!.bounds) contentView.autoresizingMask = [.width, .height] - // Header + // Header + selection toast (use autolayout for baseline alignment) let headerLabel = NSTextField(labelWithString: "Projects") headerLabel.font = NSFont.boldSystemFont(ofSize: 14) - headerLabel.sizeToFit() - headerLabel.frame.origin = NSPoint(x: 16, y: 410) + headerLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(headerLabel) selectionToast = NSTextField(labelWithString: "") - selectionToast.font = NSFont.systemFont(ofSize: 12) + selectionToast.font = NSFont.systemFont(ofSize: 13) selectionToast.textColor = .systemBlue selectionToast.alphaValue = 0 - let toastX = headerLabel.frame.maxX + 8 - selectionToast.frame = NSRect(x: toastX, y: headerLabel.frame.origin.y, width: 480, height: headerLabel.frame.height) + selectionToast.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(selectionToast) + NSLayoutConstraint.activate([ + headerLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + headerLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: -30), + selectionToast.lastBaselineAnchor.constraint(equalTo: headerLabel.lastBaselineAnchor), + selectionToast.leadingAnchor.constraint(equalTo: headerLabel.trailingAnchor, constant: 8), + ]) + // Refresh button let refreshButton = NSButton( title: "Refresh", target: self, action: #selector(refreshProjects)) @@ -282,7 +287,7 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { private static let dateFormatter: DateFormatter = { let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm" + f.dateFormat = "yyyy-MM-dd HH:mm:ss" return f }() From b43b45e669330c38dd8eb64a00744f6b7b983bba Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 01:11:02 -0700 Subject: [PATCH 12/14] fix(ui): restore Projects header and toast visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autolayout topAnchor with negative offset pushed both labels off-screen. Switch back to frame-based positioning at y=410 which worked before. Use same bold 14pt font for both header and toast so they align naturally at the same origin and height. 🤖 Built with SMT --- .../Menubar/ProjectsWindowController.swift | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 188ede3..8d1742e 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -43,26 +43,25 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let contentView = NSView(frame: window.contentView!.bounds) contentView.autoresizingMask = [.width, .height] - // Header + selection toast (use autolayout for baseline alignment) + // Header + selection toast let headerLabel = NSTextField(labelWithString: "Projects") headerLabel.font = NSFont.boldSystemFont(ofSize: 14) - headerLabel.translatesAutoresizingMaskIntoConstraints = false + headerLabel.sizeToFit() + headerLabel.frame = NSRect(x: 16, y: 410, width: headerLabel.frame.width, height: headerLabel.frame.height) contentView.addSubview(headerLabel) selectionToast = NSTextField(labelWithString: "") - selectionToast.font = NSFont.systemFont(ofSize: 13) + selectionToast.font = NSFont.boldSystemFont(ofSize: 14) selectionToast.textColor = .systemBlue selectionToast.alphaValue = 0 - selectionToast.translatesAutoresizingMaskIntoConstraints = false + selectionToast.frame = NSRect( + x: headerLabel.frame.maxX + 8, + y: headerLabel.frame.origin.y, + width: 400, + height: headerLabel.frame.height + ) contentView.addSubview(selectionToast) - NSLayoutConstraint.activate([ - headerLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - headerLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: -30), - selectionToast.lastBaselineAnchor.constraint(equalTo: headerLabel.lastBaselineAnchor), - selectionToast.leadingAnchor.constraint(equalTo: headerLabel.trailingAnchor, constant: 8), - ]) - // Refresh button let refreshButton = NSButton( title: "Refresh", target: self, action: #selector(refreshProjects)) From 8c1fedcc756b7c36779f0b4712a80bbe043a00f6 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 01:13:14 -0700 Subject: [PATCH 13/14] fix(ui): use monospaced digits for Created timestamp column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- Sources/Menubar/ProjectsWindowController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 8d1742e..595456b 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -271,7 +271,11 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { case createdColId: let dateStr = Self.formatDate(project.created) - return makeLabelCell(tableView, id: "createdCell", text: dateStr) + let cell = makeLabelCell(tableView, id: "createdCell", text: dateStr) + if let cellView = cell as? NSTableCellView { + cellView.textField?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + } + return cell default: return nil From 54d6fed4b50aba48947f6567e7758aaae1574dc7 Mon Sep 17 00:00:00 2001 From: Brent G Date: Fri, 29 May 2026 01:15:44 -0700 Subject: [PATCH 14/14] fix(ui): widen Created column to fit full timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- Sources/Menubar/ProjectsWindowController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Menubar/ProjectsWindowController.swift b/Sources/Menubar/ProjectsWindowController.swift index 595456b..d5f696c 100644 --- a/Sources/Menubar/ProjectsWindowController.swift +++ b/Sources/Menubar/ProjectsWindowController.swift @@ -113,8 +113,8 @@ class ProjectsWindowController: NSObject, NSWindowDelegate { let createdCol = NSTableColumn(identifier: createdColId) createdCol.title = "Created" - createdCol.width = 130 - createdCol.minWidth = 90 + createdCol.width = 160 + createdCol.minWidth = 150 table.addTableColumn(createdCol) scrollView.documentView = table