Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

156 changes: 155 additions & 1 deletion NotchBuddy/Sources/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,44 @@ final class AppState: ObservableObject {
}
}

// Vercel scope — nil = personal account, else the team ID sent as ?teamId=
@Published var vercelTeamId: String? = nil {
didSet { UserDefaults.standard.set(vercelTeamId, forKey: "vercelTeamId") }
}
@Published var vercelTeamName: String? = nil {
didSet { UserDefaults.standard.set(vercelTeamName, forKey: "vercelTeamName") }
}

// Vercel project in focus in the notch — nil = all watched projects
@Published var vercelFocusProject: String? = nil {
didSet { UserDefaults.standard.set(vercelFocusProject, forKey: "vercelFocusProject") }
}

// GitHub repos to watch (nameWithOwner) — empty = the 10 most recently pushed owned repos
@Published var githubWatchedRepos: Set<String> = [] {
didSet {
if let data = try? JSONEncoder().encode(Array(githubWatchedRepos)) {
UserDefaults.standard.set(data, forKey: "githubWatchedRepos")
}
if githubWatchedRepos != oldValue { GithubPoller.shared.triggerPulseNow() }
}
}

// Right card panel that temporarily replaces the agent pills (nil = pills). Not persisted:
// closed when the focus changes, the island collapses or another view takes over.
@Published var sidePanel: SidePanel? = nil

// Claude Code sessions → their git checkout (by task id) and that branch's PR/CI (by "repo@branch")
@Published var sessionGit: [String: GitRepoInfo] = [:]
@Published var sessionBranches: [String: SessionBranchInfo] = [:]
// The Claude Code session last seen on each pill (from the hooks), to continue it from the notch or the iPhone
@Published var claudeSessions: [String: ClaudeSessionRef] = [:]

// GitHub repo in focus in the notch — nil = all watched repos
@Published var githubFocusRepo: String? = nil {
didSet { UserDefaults.standard.set(githubFocusRepo, forKey: "githubFocusRepo") }
}

// n8n workflow filter — empty = watch all workflows
@Published var n8nWorkflowFilter: Set<String> = [] {
didSet {
Expand Down Expand Up @@ -468,6 +506,12 @@ final class AppState: ObservableObject {
if let v = ud.string(forKey: "islandDisplay") { islandDisplay = IslandDisplayChoice(storageValue: v) }
if let d = ud.data(forKey: "vercelProjectFilter"),
let a = try? JSONDecoder().decode([String].self, from: d) { vercelProjectFilter = Set(a) }
vercelTeamId = ud.string(forKey: "vercelTeamId")
vercelTeamName = ud.string(forKey: "vercelTeamName")
vercelFocusProject = ud.string(forKey: "vercelFocusProject")
if let d = ud.data(forKey: "githubWatchedRepos"),
let a = try? JSONDecoder().decode([String].self, from: d) { githubWatchedRepos = Set(a) }
githubFocusRepo = ud.string(forKey: "githubFocusRepo")
if let d = ud.data(forKey: "n8nWorkflowFilter"),
let a = try? JSONDecoder().decode([String].self, from: d) { n8nWorkflowFilter = Set(a) }
if let d = ud.data(forKey: "activeIntegrations"),
Expand Down Expand Up @@ -497,6 +541,85 @@ final class AppState: ObservableObject {
tasks.first { $0.id == focusId } ?? tasks.first
}

// MARK: - Side panel

/// Opens a panel in the right card, or closes it when it is already open.
func toggleSidePanel(_ panel: SidePanel) {
sidePanel = sidePanel == panel ? nil : panel
SoundEngine.shared.play("blip")
}

// MARK: - Session → GitHub / Vercel

func sessionBranchInfo(for taskId: String) -> SessionBranchInfo? {
guard let git = sessionGit[taskId] else { return nil }
return sessionBranches["\(git.repo)@\(git.branch)"]
}

/// The newest Vercel deployment built from this branch of this repo (repo unknown = branch only).
func vercelPreview(repo: String, branch: String?) -> VercelDeployment? {
guard let branch, !branch.isEmpty else { return nil }
return vercelDeployments.first {
$0.branch == branch && ($0.repo == nil || $0.repo?.caseInsensitiveCompare(repo) == .orderedSame)
}
}

// MARK: - Watch lists and notch focus (Vercel / GitHub)

/// Deployments of the watched projects (all projects when the filter is empty).
var vercelWatchedDeployments: [VercelDeployment] {
vercelProjectFilter.isEmpty ? vercelDeployments
: vercelDeployments.filter { vercelProjectFilter.contains($0.projectName) }
}

/// Projects offered by the notch picker: the watch list, or the projects seen in recent deployments.
var vercelFocusOptions: [String] {
if !vercelProjectFilter.isEmpty { return vercelProjectFilter.sorted() }
var seen = Set<String>()
return vercelDeployments.map(\.projectName).filter { seen.insert($0).inserted }.sorted()
}

/// The focused project, ignored when it is no longer on the watch list.
var effectiveVercelFocus: String? {
guard let p = vercelFocusProject, vercelFocusOptions.contains(p) else { return nil }
return p
}

/// Deployments shown in the notch: watched, narrowed to the focused project.
var vercelVisibleDeployments: [VercelDeployment] {
guard let p = effectiveVercelFocus else { return vercelWatchedDeployments }
return vercelWatchedDeployments.filter { $0.projectName == p }
}

/// Repos offered by the notch picker: the watch list, or the repos in the latest pulse.
/// "coucou" for "owner/coucou", or the full "owner/coucou" when another option has the same name.
func githubRepoLabel(_ repo: String) -> String {
let name = repo.split(separator: "/").last.map(String.init) ?? repo
let clash = githubFocusOptions.contains { $0 != repo && $0.split(separator: "/").last.map(String.init) == name }
return clash ? repo : name
}

var githubFocusOptions: [String] {
if !githubWatchedRepos.isEmpty { return githubWatchedRepos.sorted() }
guard let p = githubPulse else { return [] }
var seen = Set<String>()
return (p.mainCI.map(\.repo) + p.myPRs.map(\.repo) + p.toReview.map(\.repo))
.filter { seen.insert($0).inserted }.sorted()
}

/// The focused repo, ignored when it is no longer offered.
var effectiveGithubFocus: String? {
guard let r = githubFocusRepo, githubFocusOptions.contains(r) else { return nil }
return r
}

/// Pulse shown in the notch, narrowed to the focused repo.
var githubVisiblePulse: GitHubPulse? {
guard let p = githubPulse else { return nil }
guard let r = effectiveGithubFocus else { return p }
return p.filtered(toRepos: [r])
}

var effectiveState: BotState {
stateOverride ?? focusTask?.state ?? .idle
}
Expand Down Expand Up @@ -687,6 +810,17 @@ struct ResultItem {
var url: String?
}

// MARK: - Side panel (right card)

enum SidePanel: Equatable {
case githubRepos // pick the repo in focus
case vercelProjects // pick the project in focus
case github(GitHubDetailSection) // PRs, reviews or default-branch CI
case vercelDeployment(String) // one deployment, by id
case sessionBranch(String) // a Claude Code session's PR, CI and preview, by task id
case reply(String) // continue a Claude Code session from the notch, by task id
}

// MARK: - Vercel

struct VercelDeployment: Identifiable {
Expand All @@ -697,9 +831,29 @@ struct VercelDeployment: Identifiable {
let createdAt: Date
let commitMessage: String?
let branch: String?
var target: String? = nil // "production", "staging"… nil = preview
var repo: String? = nil // "org/repo" of the git source, when Vercel reports it

var isSuccess: Bool { state == "READY" }
var statusLabel: String { isSuccess ? "Ready" : (state == "CANCELED" ? "Canceled" : "Error") }
var isBuilding: Bool { ["BUILDING", "QUEUED", "INITIALIZING"].contains(state) }
var isTerminal: Bool { ["READY", "ERROR", "CANCELED"].contains(state) }
var statusLabel: String {
switch state {
case "READY": return "Ready"
case "CANCELED": return "Canceled"
case "BUILDING": return "Building"
case "QUEUED": return "Queued"
case "INITIALIZING": return "Starting"
default: return "Error"
}
}
/// Dot color: green ready, amber building, grey canceled, red error.
var stateColor: String {
if isSuccess { return "#22C55E" }
if isBuilding { return "#F5A524" }
if state == "CANCELED" { return "#6B7079" }
return "#F4505E"
}
var timeAgo: String {
let diff = Date().timeIntervalSince(createdAt)
if diff < 60 { return "just now" }
Expand Down
84 changes: 84 additions & 0 deletions NotchBuddy/Sources/App/ClaudeHost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import AppKit

// MARK: - ClaudeHost
//
// The app a Claude Code session runs in, from the hook payload's term_program /
// bundle_id. VS Code sessions keep the original "VS Code" pill; sessions from a
// terminal also route to integration_claude and the pill says "Claude Code".

/// The Claude Code session last seen on a pill, from the hooks.
struct ClaudeSessionRef: Equatable {
let sessionId: String
let cwd: String
let hostApp: String? // bundle id of the terminal, nil = VS Code
}

struct ClaudeHost: Equatable {
let bundleId: String
let name: String

/// UserDefaults key: show terminal sessions' questions and permission requests in the
/// notch (they then wait for the notch). Off by default: the terminal asks itself.
static let terminalCardsKey = "terminalCardsEnabled"
static var terminalCardsEnabled: Bool { UserDefaults.standard.bool(forKey: terminalCardsKey) }

/// Terminals accepted for Claude Code sessions. Keyed by bundle id; the second
/// table maps TERM_PROGRAM for hooks that run without __CFBundleIdentifier.
private static let terminals: [String: String] = [
"dev.warp.Warp-Stable": "Warp",
"dev.warp.Warp-Preview": "Warp",
"com.apple.Terminal": "Terminal",
"com.googlecode.iterm2": "iTerm",
"com.mitchellh.ghostty": "Ghostty",
"net.kovidgoyal.kitty": "kitty",
"org.alacritty": "Alacritty",
"com.github.wez.wezterm": "WezTerm",
"co.zeit.hyper": "Hyper",
"dev.zed.Zed": "Zed",
"com.cmuxterm.app": "cmux", // sets TERM_PROGRAM=ghostty: the bundle id decides
"com.stablyai.orca": "Orca",
]
private static let termPrograms: [String: String] = [
"warpterminal": "dev.warp.Warp-Stable",
"apple_terminal": "com.apple.Terminal",
"iterm.app": "com.googlecode.iterm2",
"ghostty": "com.mitchellh.ghostty",
"kitty": "net.kovidgoyal.kitty",
"alacritty": "org.alacritty",
"wezterm": "com.github.wez.wezterm",
"hyper": "co.zeit.hyper",
"zed": "dev.zed.Zed",
]

/// The terminal a session runs in, or nil when it isn't a known terminal.
static func terminal(termProgram: String, bundleId: String) -> ClaudeHost? {
if let name = terminals[bundleId] { return ClaudeHost(bundleId: bundleId, name: name) }
if let id = termPrograms[termProgram.lowercased()], let name = terminals[id] {
return ClaudeHost(bundleId: id, name: name)
}
return nil
}

/// The app name for a task's host; nil host means VS Code (the original routing).
static func name(for hostBundleId: String?) -> String {
guard let id = hostBundleId, let name = terminals[id] else { return "VS Code" }
return name
}

/// Pill label for integration_claude: "VS Code" for editor sessions, "Claude Code" otherwise.
static func pillName(hostApp: String?) -> String {
hostApp == nil ? "VS Code" : "Claude Code"
}

/// Brings the session's terminal forward (launching it if needed). false when not a terminal host.
@discardableResult
static func activate(_ hostBundleId: String?) -> Bool {
guard let id = hostBundleId, terminals[id] != nil else { return false }
if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == id }) {
running.activate()
} else if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id) {
NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil)
}
return true
}
}
Loading