From f2d0f5efbe2e1f4cc7e8ecfe2ef12ec327874414 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 21:50:21 -0600 Subject: [PATCH 1/6] GitHub/Vercel: watch lists, right-side detail panel, actions from the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: pick watched GitHub repos and a Vercel team + projects; quick-switch the focused repo/project from the card header. - Right card swaps to a detail panel for a PR or deployment (✕ closes, pills come back when an agent needs approval or asks a question). - Merge/approve/rerun and redeploy/promote/cancel from the Mac notch (shared ServiceAPI, moved out of the iPhone-only runner). - Vercel building state and failure reason; PR → preview deployment. - Claude Code session → branch/PR chip and panel (GitRepoInfo, SessionGitHubLinker). Vercel polls only while its pill is active. Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 24 + NotchBuddy/Sources/App/AppState.swift | 146 ++- NotchBuddy/Sources/App/GitHubPulse.swift | 187 +++- NotchBuddy/Sources/App/GitRepoInfo.swift | 98 ++ NotchBuddy/Sources/App/GithubPoller.swift | 48 +- .../Sources/App/IslandViewContent.swift | 249 ++--- .../App/PhoneLink/ServiceDetailRunner.swift | 638 ------------- .../App/PhoneLink/ServicePublisher.swift | 3 +- NotchBuddy/Sources/App/ServiceAPI.swift | 685 ++++++++++++++ .../Sources/App/SessionGitHubLinker.swift | 57 ++ NotchBuddy/Sources/App/SettingsView.swift | 184 +++- NotchBuddy/Sources/App/SidePanelView.swift | 857 ++++++++++++++++++ NotchBuddy/Sources/App/VercelPoller.swift | 83 +- scripts/test-git-repo-info.sh | 8 + tests/GitHubPulseTests.swift | 79 ++ tests/GitRepoInfoTests.swift | 64 ++ 16 files changed, 2572 insertions(+), 838 deletions(-) create mode 100644 NotchBuddy/Sources/App/GitRepoInfo.swift create mode 100644 NotchBuddy/Sources/App/ServiceAPI.swift create mode 100644 NotchBuddy/Sources/App/SessionGitHubLinker.swift create mode 100644 NotchBuddy/Sources/App/SidePanelView.swift create mode 100755 scripts/test-git-repo-info.sh create mode 100644 tests/GitRepoInfoTests.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index fcd1ac75c3..617a4d8222 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -25,11 +25,13 @@ 0E83C1DD4D20E7FB7A904450 /* VercelPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */; }; 0FAAA8683282E9E8EA9E2416 /* AgentNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D05714C97CF5CAB32DCE7 /* AgentNotifier.swift */; }; 118FC4AE0AC74D84B4A134D8 /* VercelPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */; }; + 119CDDD94C1320FD4B2DEC8D /* SidePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F31F7C3961439BAB9A8CF1 /* SidePanelView.swift */; }; 11F49CD9D12E69CE18DFEDCC /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; 134D136F3CF0B91AEBCA84EB /* finish.wav in Resources */ = {isa = PBXBuildFile; fileRef = 37147F42613183DBA8896835 /* finish.wav */; }; 13663D98590927AF71C17628 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; 1558BB00C530B721172F6E65 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; 160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */; }; + 16513E8398CC78F538EBBE8D /* GitRepoInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85588295C63115CAF2C6C8E2 /* GitRepoInfo.swift */; }; 17392C63757FBAC38F22B9D6 /* ServiceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08128342A3F894D567AD9E6B /* ServiceViews.swift */; }; 17D6063D3B374FB82340314E /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; 18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */; }; @@ -123,10 +125,12 @@ 875FB3F71BD34B53B0524063 /* CoucouWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6723392BE01AF210E484316 /* CoucouWidgets.swift */; }; 878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; }; 8881BC510155DEC038F3B552 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; + 8A6809F8D00B59A697AAC90E /* SessionGitHubLinker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */; }; 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 8AC19BAF88822CB31519D447 /* CoucouIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708E1FD9894461B4AD727DB1 /* CoucouIntents.swift */; }; 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; 8C785917FD0740DAE56420A4 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; + 8C9878C52623BABE6FEF64C0 /* SessionGitHubLinker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */; }; 8C9AF392DAE8F2098A7BE7B6 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; 8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; 8DF63B85A54090AEA72A246C /* KitPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E39852175E81C9632A59C9 /* KitPreviewView.swift */; }; @@ -149,11 +153,14 @@ 9BCD79FD8CF211DC8FBD2971 /* SearchAndSpotlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE60A834934474CDAE022FF8 /* SearchAndSpotlight.swift */; }; 9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F142FB3952B5F0639383047 /* MusicController.swift */; }; 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; + 9E8D6E6EC4B179A62E518F85 /* GitRepoInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85588295C63115CAF2C6C8E2 /* GitRepoInfo.swift */; }; 9ECEE890D4486B6E83BD5B7F /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; 9F43BE6E77E371FCEB7FE8BA /* ServiceDetailRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */; }; A7733EE60694EA3E2A86C37A /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; A7EEF05A08F6B95370712089 /* error.wav in Resources */ = {isa = PBXBuildFile; fileRef = 871CA2BADC67D97C97C7009D /* error.wav */; }; + A877D432130EBC32829E75C8 /* ServiceAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */; }; A885B7E6DC43231BECE36E3D /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; + A93ED34BA35D57DFA5CEA032 /* ServiceAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */; }; AD94156F1F9CFF62B35202FB /* ApprovalRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD426E81DF79CA79888059D9 /* ApprovalRelay.swift */; }; ADB4EF797FBD6CC90F8D25D3 /* MochiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */; }; ADBB9C15DDA4044625B325BE /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; @@ -217,6 +224,7 @@ E82CA1269BF4859EEDE322AE /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */; }; EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; + EBF70CF39708737328DFD7A1 /* SidePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F31F7C3961439BAB9A8CF1 /* SidePanelView.swift */; }; EC7904DC421C6EB6DCD77889 /* DenyApprovalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */; }; ECECA2F225E82B7C951658D7 /* UploadSequenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */; }; ED484F8DAC497C77F3E81F0E /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; @@ -309,9 +317,11 @@ 4F142FB3952B5F0639383047 /* MusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicController.swift; sourceTree = ""; }; 506824380E9C80B9BC6DCE72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5108F622EF0C553F053716C2 /* SessionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStatus.swift; sourceTree = ""; }; + 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceAPI.swift; sourceTree = ""; }; 5C3E2269A202B640E7F3B5E0 /* CoucouWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CoucouWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5C406C508A86E215B3CBE3FC /* CoucouControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouControl.swift; sourceTree = ""; }; 60401D1023D17C3FD30D7113 /* PhoneAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = PhoneAssets.xcassets; sourceTree = ""; }; + 60F31F7C3961439BAB9A8CF1 /* SidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidePanelView.swift; sourceTree = ""; }; 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendPoller.swift; sourceTree = ""; }; 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDetail.swift; sourceTree = ""; }; @@ -328,9 +338,11 @@ 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiWardrobe.swift; sourceTree = ""; }; 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = ""; }; 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionGitHubLinker.swift; sourceTree = ""; }; 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPublisher.swift; sourceTree = ""; }; 8296E14A0C15515E6C3C1DBB /* QuestionCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionCard.swift; sourceTree = ""; }; 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceSnapshot.swift; sourceTree = ""; }; + 85588295C63115CAF2C6C8E2 /* GitRepoInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepoInfo.swift; sourceTree = ""; }; 871CA2BADC67D97C97C7009D /* error.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = error.wav; sourceTree = ""; }; 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; 888B3E1420BCB6292BDC8117 /* WeeklyRecapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRecapView.swift; sourceTree = ""; }; @@ -425,6 +437,7 @@ 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */, + 85588295C63115CAF2C6C8E2 /* GitRepoInfo.swift */, C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */, 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */, 1218B86E249691AF44F819AC /* HotKeyCenter.swift */, @@ -440,9 +453,12 @@ 78FB882D49084DEA6A10DB4D /* RecapStore.swift */, 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */, B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, + 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */, + 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, + 60F31F7C3961439BAB9A8CF1 /* SidePanelView.swift */, 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */, 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */, B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */, @@ -801,6 +817,7 @@ E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */, D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */, 862CE6CB220D55E46F646DA9 /* GitHubPulse.swift in Sources */, + 9E8D6E6EC4B179A62E518F85 /* GitRepoInfo.swift in Sources */, C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */, 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */, 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */, @@ -828,14 +845,17 @@ 7A449E237CAAB62758508C7C /* RecapStore.swift in Sources */, 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */, 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */, + A877D432130EBC32829E75C8 /* ServiceAPI.swift in Sources */, 2A30FA2F046492E22A1C5719 /* ServiceDetail.swift in Sources */, 9F43BE6E77E371FCEB7FE8BA /* ServiceDetailRunner.swift in Sources */, 694AB4504CD65662E1681E5C /* ServicePublisher.swift in Sources */, 0393CA5466F52252378F0346 /* ServiceSnapshot.swift in Sources */, + 8C9878C52623BABE6FEF64C0 /* SessionGitHubLinker.swift in Sources */, 9728564FCBF0334E68E9E6E7 /* SessionPublisher.swift in Sources */, 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */, C07E9A04E827284FDF33DB16 /* ShortcutLogic.swift in Sources */, 1EAFEE80A8066B77F9410025 /* ShortcutsSettingsView.swift in Sources */, + EBF70CF39708737328DFD7A1 /* SidePanelView.swift in Sources */, 60E361FFE84C387E5E3ED1AA /* SoundEngine.swift in Sources */, 1558BB00C530B721172F6E65 /* StripePoller.swift in Sources */, 0408FE7F86EBEE61CAED32DA /* TurnRecorder.swift in Sources */, @@ -951,6 +971,7 @@ 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */, 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */, 38EEB2D3A6EC538958D92575 /* GitHubPulse.swift in Sources */, + 16513E8398CC78F538EBBE8D /* GitRepoInfo.swift in Sources */, C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */, C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */, 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */, @@ -978,14 +999,17 @@ B69DE31E2B0C2E35594CAA87 /* RecapStore.swift in Sources */, EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */, FEFEF1EBAB0E6CA74A33C10B /* SafeWebURL.swift in Sources */, + A93ED34BA35D57DFA5CEA032 /* ServiceAPI.swift in Sources */, 8C9AF392DAE8F2098A7BE7B6 /* ServiceDetail.swift in Sources */, 4755AAC633DCE5DAA6D54A3C /* ServiceDetailRunner.swift in Sources */, D092D0D597D1CBCE6B775B5E /* ServicePublisher.swift in Sources */, 1FE62D4EEA85FB25C17FDBA2 /* ServiceSnapshot.swift in Sources */, + 8A6809F8D00B59A697AAC90E /* SessionGitHubLinker.swift in Sources */, DE66BB6D18F467A7F4220CC3 /* SessionPublisher.swift in Sources */, 8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */, 6CCEC73ED68E9F253C22D692 /* ShortcutLogic.swift in Sources */, 908043F62E2C31F5FBC0A0C4 /* ShortcutsSettingsView.swift in Sources */, + 119CDDD94C1320FD4B2DEC8D /* SidePanelView.swift in Sources */, 78B3C6814431B2DD9D1F4949 /* SoundEngine.swift in Sources */, E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */, CE0A1BD64DF3E08D200691A6 /* TurnRecorder.swift in Sources */, diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 22a097cfbf..96d871de85 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -277,6 +277,42 @@ 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 = [] { + 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] = [:] + + // 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 = [] { didSet { @@ -443,6 +479,12 @@ final class AppState: ObservableObject { if let v = ud.object(forKey: "hotkeyCode") as? Int { hotkeyCode = UInt16(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"), @@ -471,6 +513,78 @@ 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() + 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. + var githubFocusOptions: [String] { + if !githubWatchedRepos.isEmpty { return githubWatchedRepos.sorted() } + guard let p = githubPulse else { return [] } + var seen = Set() + 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 } @@ -661,6 +775,16 @@ 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 +} + // MARK: - Vercel struct VercelDeployment: Identifiable { @@ -671,9 +795,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" } diff --git a/NotchBuddy/Sources/App/GitHubPulse.swift b/NotchBuddy/Sources/App/GitHubPulse.swift index 6cb21d4931..99d5698566 100644 --- a/NotchBuddy/Sources/App/GitHubPulse.swift +++ b/NotchBuddy/Sources/App/GitHubPulse.swift @@ -44,6 +44,7 @@ struct GitHubPR: Equatable { var ci: CIState var review: ReviewState var headSha: String? = nil // oid of last commit; nil if not fetched + var headRef: String? = nil // head branch name; nil if not fetched } // MARK: - GitHubRepoCI @@ -84,6 +85,16 @@ struct GitHubPulse: Equatable { mainCI.contains { $0.ci == .pending } } + /// Keeps only PRs and default-branch CI from the given repos (nameWithOwner). Empty set = unchanged. + func filtered(toRepos repos: Set) -> GitHubPulse { + guard !repos.isEmpty else { return self } + var copy = self + copy.myPRs = myPRs.filter { repos.contains($0.repo) } + copy.toReview = toReview.filter { repos.contains($0.repo) } + copy.mainCI = mainCI.filter { repos.contains($0.repo) } + return copy + } + // MARK: - Parse static func parse(_ data: Data) -> GitHubPulse? { @@ -119,30 +130,42 @@ struct GitHubPulse: Equatable { myPRs.append(GitHubPR( id: id, title: title, url: url, repo: repo, number: number, isDraft: isDraft, ci: CIState(rawGitHub: ciRaw), - review: ReviewState(rawGitHub: reviewDecision), headSha: headSha + review: ReviewState(rawGitHub: reviewDecision), headSha: headSha, + headRef: node["headRefName"] as? String )) } } - // Main CI for recently-pushed repos (client-side filter: skip archived) - var mainCI: [GitHubRepoCI] = [] + // Main CI: recently-pushed repos, or the watched repos queried as aliases r0, r1… + // (client-side filter: skip archived) + var repoNodes: [[String: Any]] = [] if let repoConn = viewer["repositories"] as? [String: Any], let nodes = repoConn["nodes"] as? [[String: Any]] { - for node in nodes { - let isArchived = node["isArchived"] as? Bool ?? false - if isArchived { continue } - guard let repo = node["nameWithOwner"] as? String, - let url = node["url"] as? String, - let branchRef = node["defaultBranchRef"] as? [String: Any], - let branch = branchRef["name"] as? String else { continue } - let (ciRaw, headSha): (String?, String?) = { - guard let target = branchRef["target"] as? [String: Any] else { return (nil, nil) } - let rollup = target["statusCheckRollup"] as? [String: Any] - return (rollup?["state"] as? String, target["oid"] as? String) - }() - mainCI.append(GitHubRepoCI(repo: repo, url: url, branch: branch, - ci: CIState(rawGitHub: ciRaw), headSha: headSha)) + repoNodes = nodes + } + let aliasKeys = dataNode.keys + .compactMap { key -> (Int, String)? in + guard key.hasPrefix("r"), let i = Int(key.dropFirst()) else { return nil } + return (i, key) } + .sorted { $0.0 < $1.0 } + repoNodes += aliasKeys.compactMap { dataNode[$0.1] as? [String: Any] } + + var mainCI: [GitHubRepoCI] = [] + for node in repoNodes { + let isArchived = node["isArchived"] as? Bool ?? false + if isArchived { continue } + guard let repo = node["nameWithOwner"] as? String, + let url = node["url"] as? String, + let branchRef = node["defaultBranchRef"] as? [String: Any], + let branch = branchRef["name"] as? String else { continue } + let (ciRaw, headSha): (String?, String?) = { + guard let target = branchRef["target"] as? [String: Any] else { return (nil, nil) } + let rollup = target["statusCheckRollup"] as? [String: Any] + return (rollup?["state"] as? String, target["oid"] as? String) + }() + mainCI.append(GitHubRepoCI(repo: repo, url: url, branch: branch, + ci: CIState(rawGitHub: ciRaw), headSha: headSha)) } // To review @@ -242,3 +265,133 @@ struct GitHubPulse: Equatable { return now.timeIntervalSince(t) > maxAge } } + +// MARK: - Query + +extension GitHubPulse { + /// Pulse query. With no watched repos: the 10 most recently pushed owned repos. + /// With watched repos: each one fetched by name as an alias r0, r1… (parsed in GitHubPulse.parse). + static func query(watched: [String]) -> String { + let repoFields = """ + nameWithOwner url isArchived + defaultBranchRef { + name + target { ... on Commit { oid statusCheckRollup { state } } } + } + """ + let viewerRepos = watched.isEmpty ? """ + repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { + nodes { + \(repoFields) + } + } + """ : "" + let aliases = watched.compactMap(repoOwnerAndName).enumerated().map { i, pair in + " r\(i): repository(owner: \"\(pair.0)\", name: \"\(pair.1)\") { \(repoFields) }" + }.joined(separator: "\n") + return """ + query { + viewer { + login + pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft reviewDecision headRefName + repository { nameWithOwner url } + commits(last: 1) { + nodes { commit { oid statusCheckRollup { state } } } + } + } + } + \(viewerRepos) + } + reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) { + issueCount + nodes { + ... on PullRequest { + number title url isDraft + author { login } + repository { nameWithOwner url } + } + } + } + \(aliases) + } + """ + } + + /// "owner/name" → ("owner", "name"), only when both parts are safe to put in a GraphQL string. + static func repoOwnerAndName(_ nameWithOwner: String) -> (String, String)? { + let parts = nameWithOwner.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") + guard parts.count == 2, + parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }) else { return nil } + return (parts[0], parts[1]) + } +} + +// MARK: - SessionBranchInfo +// +// The open PR and the CI of the branch a Claude Code session works on. + +struct SessionBranchInfo: Equatable { + var repo: String + var branch: String + var pr: GitHubPR? + var branchCI: CIState + var fetchedAt: Date + + var hasPending: Bool { branchCI == .pending || pr?.ci == .pending } + + /// Values go in GraphQL variables, never in the query text. + static let query = """ + query($owner: String!, $name: String!, $branch: String!, $qualified: String!) { + repository(owner: $owner, name: $name) { + pullRequests(headRefName: $branch, states: [OPEN], first: 10, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft reviewDecision headRefName + headRepository { nameWithOwner } + commits(last: 1) { nodes { commit { oid statusCheckRollup { state } } } } + } + } + ref(qualifiedName: $qualified) { + target { ... on Commit { oid statusCheckRollup { state } } } + } + } + } + """ + + static func variables(repo: String, branch: String) -> [String: String]? { + let parts = repo.split(separator: "/").map(String.init) + guard parts.count == 2 else { return nil } + return ["owner": parts[0], "name": parts[1], "branch": branch, "qualified": "refs/heads/\(branch)"] + } + + static func parse(_ data: Data, repo: String, branch: String, now: Date = Date()) -> SessionBranchInfo? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataNode = root["data"] as? [String: Any], + let repository = dataNode["repository"] as? [String: Any] else { return nil } + var pr: GitHubPR? = nil + // headRefName also matches PRs from forks with a same-named branch: keep this repo's own. + let nodes = (repository["pullRequests"] as? [String: Any])?["nodes"] as? [[String: Any]] ?? [] + let own = nodes.first { + let head = ($0["headRepository"] as? [String: Any])?["nameWithOwner"] as? String + return head?.caseInsensitiveCompare(repo) == .orderedSame + } + if let node = own, + let number = node["number"] as? Int, + let title = node["title"] as? String, + let url = node["url"] as? String { + let commit = ((node["commits"] as? [String: Any])?["nodes"] as? [[String: Any]])?.last?["commit"] as? [String: Any] + let rollup = commit?["statusCheckRollup"] as? [String: Any] + pr = GitHubPR(id: "\(repo)#\(number)", title: title, url: url, repo: repo, number: number, + isDraft: node["isDraft"] as? Bool ?? false, + ci: CIState(rawGitHub: rollup?["state"] as? String), + review: ReviewState(rawGitHub: node["reviewDecision"] as? String), + headSha: commit?["oid"] as? String, + headRef: node["headRefName"] as? String ?? branch) + } + let target = (repository["ref"] as? [String: Any])?["target"] as? [String: Any] + let ci = CIState(rawGitHub: (target?["statusCheckRollup"] as? [String: Any])?["state"] as? String) + return SessionBranchInfo(repo: repo, branch: branch, pr: pr, branchCI: ci, fetchedAt: now) + } +} diff --git a/NotchBuddy/Sources/App/GitRepoInfo.swift b/NotchBuddy/Sources/App/GitRepoInfo.swift new file mode 100644 index 0000000000..80c3c4e76b --- /dev/null +++ b/NotchBuddy/Sources/App/GitRepoInfo.swift @@ -0,0 +1,98 @@ +import Foundation + +// MARK: - GitRepoInfo +// +// The GitHub repo and current branch of a working directory, read from the files of +// its .git folder (no `git` process). Worktrees and submodules (a `.git` file with +// "gitdir: …") are followed. nil when the folder isn't a git checkout of a GitHub +// repo, is on a detached HEAD, or can't be read (App Store sandbox). + +struct GitRepoInfo: Equatable { + let repo: String // "owner/name" + let branch: String + + static func read(cwd: String) -> GitRepoInfo? { + guard !cwd.isEmpty, let gitDir = findGitDir(from: URL(fileURLWithPath: cwd)) else { return nil } + guard let head = try? String(contentsOf: gitDir.appendingPathComponent("HEAD"), encoding: .utf8), + let branch = branch(fromHEAD: head) else { return nil } + // A worktree's config lives in the main repo:
/.git/worktrees/ →
/.git + let commonDir: URL = { + if let common = try? String(contentsOf: gitDir.appendingPathComponent("commondir"), encoding: .utf8) { + let path = common.trimmingCharacters(in: .whitespacesAndNewlines) + return URL(fileURLWithPath: path, relativeTo: gitDir).standardizedFileURL + } + return gitDir + }() + guard let config = try? String(contentsOf: commonDir.appendingPathComponent("config"), encoding: .utf8), + let remote = originURL(fromConfig: config), + let repo = githubRepo(fromRemote: remote) else { return nil } + return GitRepoInfo(repo: repo, branch: branch) + } + + /// Walks up from `start` to the nearest `.git` folder, following a `.git` file's "gitdir:". + static func findGitDir(from start: URL) -> URL? { + var dir = start.standardizedFileURL + let fm = FileManager.default + for _ in 0..<40 { + let candidate = dir.appendingPathComponent(".git") + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { return candidate } + if let text = try? String(contentsOf: candidate, encoding: .utf8), + let line = text.split(separator: "\n").first(where: { $0.hasPrefix("gitdir:") }) { + let path = line.dropFirst("gitdir:".count).trimmingCharacters(in: .whitespaces) + return URL(fileURLWithPath: path, relativeTo: dir).standardizedFileURL + } + return nil + } + let parent = dir.deletingLastPathComponent() + if parent.path == dir.path { break } + dir = parent + } + return nil + } + + /// "ref: refs/heads/feature/x" → "feature/x". nil on a detached HEAD. + static func branch(fromHEAD head: String) -> String? { + let line = head.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "ref: refs/heads/" + guard line.hasPrefix(prefix) else { return nil } + let branch = String(line.dropFirst(prefix.count)) + return branch.isEmpty ? nil : branch + } + + /// The url of [remote "origin"] in a git config file. + static func originURL(fromConfig config: String) -> String? { + var inOrigin = false + for raw in config.split(separator: "\n", omittingEmptySubsequences: true) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("[") { + inOrigin = line.replacingOccurrences(of: " ", with: "") == "[remote\"origin\"]" + continue + } + if inOrigin, line.hasPrefix("url") { + let parts = line.split(separator: "=", maxSplits: 1) + if parts.count == 2 { return parts[1].trimmingCharacters(in: .whitespaces) } + } + } + return nil + } + + /// "owner/name" from a github.com remote: https://github.com/o/n(.git), git@github.com:o/n.git, + /// ssh://git@github.com/o/n.git. nil for other hosts. + static func githubRepo(fromRemote remote: String) -> String? { + var path: Substring + if let range = remote.range(of: "github.com:") { + path = remote[range.upperBound...] + } else if let range = remote.range(of: "github.com/") { + path = remote[range.upperBound...] + } else { + return nil + } + if path.hasSuffix(".git") { path = path.dropLast(4) } + if path.hasSuffix("/") { path = path.dropLast() } + let parts = path.split(separator: "/") + guard parts.count == 2 else { return nil } + return "\(parts[0])/\(parts[1])" + } +} diff --git a/NotchBuddy/Sources/App/GithubPoller.swift b/NotchBuddy/Sources/App/GithubPoller.swift index 2307d263a5..6202e37066 100644 --- a/NotchBuddy/Sources/App/GithubPoller.swift +++ b/NotchBuddy/Sources/App/GithubPoller.swift @@ -101,11 +101,14 @@ final class GithubPoller: @unchecked Sendable { } self.pulseInFlight = true let gen = self.tokenGeneration - DispatchQueue.global(qos: .background).async { self.fetchPulse(token: token, generation: gen) } + let watched = AppState.shared.githubWatchedRepos.sorted() + DispatchQueue.global(qos: .background).async { + self.fetchPulse(token: token, watched: watched, generation: gen) + } } } - private func fetchPulse(token: String, generation: Int) { + private func fetchPulse(token: String, watched: [String], generation: Int) { guard let url = URL(string: "https://api.github.com/graphql") else { finishPulse(hasPending: false); return } @@ -113,7 +116,7 @@ final class GithubPoller: @unchecked Sendable { req.httpMethod = "POST" req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.graphQLQuery]) else { + guard let body = try? JSONSerialization.data(withJSONObject: ["query": GitHubPulse.query(watched: watched)]) else { finishPulse(hasPending: false); return } req.httpBody = body @@ -136,9 +139,10 @@ final class GithubPoller: @unchecked Sendable { self.finishPulse(hasPending: false); return } } - guard let pulse = GitHubPulse.parse(data) else { + guard let parsed = GitHubPulse.parse(data) else { self.finishPulse(hasPending: false); return } + let pulse = parsed.filtered(toRepos: Set(watched)) DispatchQueue.main.async { // Discard stale response if token changed while request was in flight guard self.tokenGeneration == generation else { return } @@ -293,42 +297,6 @@ final class GithubPoller: @unchecked Sendable { // MARK: - GraphQL queries - private static let graphQLQuery = """ - query { - viewer { - login - pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - number title url isDraft reviewDecision - repository { nameWithOwner url } - commits(last: 1) { - nodes { commit { oid statusCheckRollup { state } } } - } - } - } - repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { - nodes { - nameWithOwner url isArchived - defaultBranchRef { - name - target { ... on Commit { oid statusCheckRollup { state } } } - } - } - } - } - reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) { - issueCount - nodes { - ... on PullRequest { - number title url isDraft - author { login } - repository { nameWithOwner url } - } - } - } - } - """ - private static let activityQuery = """ query { viewer { diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index fe623cfa2c..2e00006d3c 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -75,6 +75,7 @@ struct OverviewView: View { .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1) .truncationMode(.tail) + SessionChips(state: state, taskId: agent.id) Spacer(minLength: 2) if agent.steps.count > 1 { Text("\(min(agent.stepIndex + 1, agent.steps.count))/\(agent.steps.count)") @@ -141,14 +142,22 @@ struct OverviewView: View { } .frame(width: 322) - // Right card: agent pills + // Right card: agent pills, or a detail panel opened from the left card CardBackground(wash: nil) { - AgentPillsView(state: state) + if let panel = state.sidePanel { + SidePanelView(state: state, panel: panel) + .transition(.opacity) + } else { + AgentPillsView(state: state) + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.16), value: state.sidePanel) } .onChange(of: state.focusId) { _, new in showingN8nDetail = false activeDiffId = nil + state.sidePanel = nil #if !APPSTORE withAnimation(.easeIn(duration: 0.16)) { state.showingPlanDetail = false } #endif @@ -159,7 +168,20 @@ struct OverviewView: View { if v != .overview { state.showingPlanDetail = false; activeDiffId = nil } } #endif + // While the island is open on a Claude Code session: its branch's PR and CI + .task(id: "\(state.focusId ?? "")|\(state.mode == .expanded)") { + guard state.mode == .expanded else { return } + while !Task.isCancelled { + if state.view == .overview { SessionGitHubLinker.shared.refresh(for: state.focusTask) } + try? await Task.sleep(for: .seconds(15)) + } + } + // Pills come back as soon as anything else needs the island (approval, question, chat…) + .onChange(of: state.view) { _, v in + if v != .overview { state.sidePanel = nil } + } .onChange(of: state.mode) { _, m in + if m != .expanded { state.sidePanel = nil } #if !APPSTORE if m != .expanded { state.showingPlanDetail = false; activeDiffId = nil } #endif @@ -1692,7 +1714,7 @@ struct IntegrationCardView: View { // Vercel with recent deployments private var vercelHasActivity: Bool { - task.id == "integration_vercel" && !appState.vercelDeployments.isEmpty + task.id == "integration_vercel" && !appState.vercelWatchedDeployments.isEmpty } // Resend with recent emails @@ -1802,15 +1824,14 @@ struct IntegrationCardView: View { withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false } } .transition(.opacity) - } else if showingDetail && vercelHasActivity { - VercelDetailView(deployment: appState.vercelDeployments[0]) { - withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false } - } - .transition(.opacity) } else if vercelHasActivity { - VercelDeploymentListView(deployments: appState.vercelDeployments, onOpenDetail: { - withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } - }) + VercelDeploymentListView( + deployments: appState.vercelVisibleDeployments, + focusLabel: appState.effectiveVercelFocus, + canPickProject: appState.vercelFocusOptions.count > 1 || appState.effectiveVercelFocus != nil, + pickerOpen: appState.sidePanel == .vercelProjects, + onTapTitle: { appState.toggleSidePanel(.vercelProjects) }, + onOpenDetail: { id in appState.toggleSidePanel(.vercelDeployment(id)) }) .transition(.opacity) } else if resendHasData { ResendCardView(emails: appState.resendEmails, total: appState.resendTotal) @@ -1818,7 +1839,7 @@ struct IntegrationCardView: View { } else if showingDetail && githubHasPulse { GitHubDetailView( section: githubDetailSection, - pulse: appState.githubPulse!, + pulse: appState.githubVisiblePulse!, activity: appState.githubActivity, stats: appState.githubStats, onBack: { @@ -1828,12 +1849,21 @@ struct IntegrationCardView: View { .transition(.opacity) } else if githubHasPulse { GitHubPulseCardView( - pulse: appState.githubPulse!, + pulse: appState.githubVisiblePulse!, stats: appState.githubStats, activity: appState.githubActivity, + focusLabel: appState.effectiveGithubFocus.map { $0.split(separator: "/").last.map(String.init) ?? $0 }, + canPickRepo: appState.githubFocusOptions.count > 1 || appState.effectiveGithubFocus != nil, + pickerOpen: appState.sidePanel == .githubRepos, + onTapTitle: { appState.toggleSidePanel(.githubRepos) }, onTapSection: { section in - githubDetailSection = section - withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } + if section == .activity { + // The contribution calendar needs the wide left card + githubDetailSection = section + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } + } else { + appState.toggleSidePanel(.github(section)) + } } ) .transition(.opacity) @@ -1870,6 +1900,7 @@ struct IntegrationCardView: View { .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1).truncationMode(.tail) + SessionChips(state: appState, taskId: task.id) Spacer(minLength: 2) if task.steps.count > 1 { Text("\(min(task.stepIndex + 1, task.steps.count))/\(task.steps.count)") @@ -2074,7 +2105,11 @@ struct IntegrationCardView: View { struct VercelDeploymentListView: View { let deployments: [VercelDeployment] - let onOpenDetail: () -> Void + var focusLabel: String? = nil + var canPickProject: Bool = false + var pickerOpen: Bool = false + var onTapTitle: () -> Void = {} + let onOpenDetail: (String) -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -2083,12 +2118,12 @@ struct VercelDeploymentListView: View { Circle() .fill(Color(hex: "#7C5CFF")) .frame(width: 7, height: 7) - Text("Vercel") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(Color(hex: "#F5F6F8")) + FocusTitleButton(title: "Vercel", selection: focusLabel, + canPick: canPickProject, isOpen: pickerOpen, action: onTapTitle) Text("Deployments") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) } .padding(.top, 6) .padding(.leading, 108) @@ -2098,7 +2133,7 @@ struct VercelDeploymentListView: View { VStack(alignment: .leading, spacing: 3) { // First deployment — highlighted, with detail button if let first = deployments.first { - let accent = Color(hex: first.isSuccess ? "#22C55E" : "#F4505E") + let accent = Color(hex: first.stateColor) HStack(spacing: 5) { Circle().fill(accent).frame(width: 5, height: 5) Text(first.projectName) @@ -2106,10 +2141,10 @@ struct VercelDeploymentListView: View { .foregroundColor(Color(hex: "#C5C8CD")) .lineLimit(1).truncationMode(.tail) .layoutPriority(1) - Text(first.timeAgo) + Text(first.isBuilding ? first.statusLabel.lowercased() + "…" : first.timeAgo) .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) - Button(action: onOpenDetail) { + Button(action: { onOpenDetail(first.id) }) { Image(systemName: "ellipsis") .font(.system(size: 8, weight: .medium)) .foregroundColor(Color(hex: "#6B7079")) @@ -2124,22 +2159,33 @@ struct VercelDeploymentListView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) } + if deployments.isEmpty { + Text("No recent deployments") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.horizontal, 8).padding(.vertical, 3) + } + // Remaining deployments — plain rows, identical structure → perfect alignment ForEach(Array(deployments.dropFirst().prefix(2))) { dep in - let accent = Color(hex: dep.isSuccess ? "#22C55E" : "#F4505E") - HStack(spacing: 5) { - Circle().fill(accent).frame(width: 5, height: 5) - Text(dep.projectName) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(Color(hex: "#9398A1")) - .lineLimit(1).truncationMode(.tail) - .layoutPriority(1) - Text(dep.timeAgo) - .font(.system(size: 10)) - .foregroundColor(Color(hex: "#6B7079")) + let accent = Color(hex: dep.stateColor) + Button(action: { onOpenDetail(dep.id) }) { + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(dep.projectName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(dep.isBuilding ? dep.statusLabel.lowercased() + "…" : dep.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - .padding(.horizontal, 8).padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) } } .padding(.top, 5) @@ -2151,81 +2197,6 @@ struct VercelDeploymentListView: View { } } -// MARK: - Vercel Deployment Detail View - -struct VercelDetailView: View { - let deployment: VercelDeployment - let onClose: () -> Void - - private var accent: Color { Color(hex: deployment.isSuccess ? "#22C55E" : "#F4505E") } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Header - HStack(spacing: 7) { - Button(action: onClose) { - Image(systemName: "chevron.left") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(Color(hex: "#6B7079")) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - Circle().fill(accent).frame(width: 6, height: 6) - Text(deployment.projectName) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(Color(hex: "#F5F6F8")) - .lineLimit(1).truncationMode(.middle) - .layoutPriority(1) - Spacer(minLength: 2) - Text(deployment.statusLabel) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(accent) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(accent.opacity(0.14)) - .clipShape(Capsule()) - } - - // Details - VStack(alignment: .leading, spacing: 4) { - if let commit = deployment.commitMessage { - Text(commit) - .font(.system(size: 10.5)) - .foregroundColor(Color(hex: "#C5C8CD")) - .lineLimit(2) - } - HStack(spacing: 8) { - if let branch = deployment.branch { - Label(branch, systemImage: "arrow.branch") - .font(.system(size: 10)) - .foregroundColor(Color(hex: "#6B7079")) - } - Text(deployment.timeAgo + " ago") - .font(.system(size: 10)) - .foregroundColor(Color(hex: "#6B7079")) - } - Button(action: { - if let url = URL(string: "https://\(deployment.url)") { - NSWorkspace.shared.open(url) - } - }) { - Text(deployment.url) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(Color(hex: "#7C5CFF").opacity(0.85)) - .lineLimit(1).truncationMode(.middle) - } - .buttonStyle(.plain) - } - } - .padding(.top, 8) - .padding(.leading, 108) - .padding(.trailing, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .contentShape(Rectangle()) - } -} - // MARK: - Resend Card View struct ResendPulseDot: View { @@ -2352,6 +2323,10 @@ struct GitHubPulseCardView: View { let pulse: GitHubPulse let stats: GitHubStats? let activity: GitHubActivity? + var focusLabel: String? = nil + var canPickRepo: Bool = false + var pickerOpen: Bool = false + var onTapTitle: () -> Void = {} let onTapSection: (GitHubDetailSection) -> Void var body: some View { @@ -2361,9 +2336,8 @@ struct GitHubPulseCardView: View { Circle() .fill(Color(hex: "#F4505E")) .frame(width: 7, height: 7) - Text("GitHub") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(Color(hex: "#F5F6F8")) + FocusTitleButton(title: "GitHub", selection: focusLabel, + canPick: canPickRepo, isOpen: pickerOpen, action: onTapTitle) if let s = stats { // Stars + 7-day mini-row → opens Activity detail Button(action: { onTapSection(.activity) }) { @@ -2468,6 +2442,49 @@ struct GitHubPulseCardView: View { } } +// MARK: - Focus title (Vercel / GitHub card headers) + +/// Card title that doubles as a quick switch: tap it to pick, in the right card, +/// which repo or project the card shows. Plain title when there is nothing to choose from. +struct FocusTitleButton: View { + let title: String + let selection: String? + let canPick: Bool + let isOpen: Bool + let action: () -> Void + @State private var isHovered = false + + private var label: some View { + Text(selection ?? title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1) + .truncationMode(.middle) + } + + var body: some View { + if canPick { + Button(action: action) { + HStack(spacing: 3) { + label + Image(systemName: "chevron.down") + .font(.system(size: 7, weight: .semibold)) + .foregroundColor(Color(hex: isHovered || isOpen ? "#C5C8CD" : "#6B7079")) + .rotationEffect(.degrees(isOpen ? 180 : 0)) + } + .frame(maxWidth: 110, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .layoutPriority(1) + .onHover { isHovered = $0 } + .animation(.easeInOut(duration: 0.16), value: isOpen) + } else { + label + } + } +} + private struct GitHubStatRow: View { let icon: String let iconColor: String @@ -2762,7 +2779,7 @@ private func contributionColor(_ level: Int) -> Color { } } -private func ghCIDot(_ ci: CIState) -> Color { +func ghCIDot(_ ci: CIState) -> Color { switch ci { case .failure: return Color(hex: "#F4505E") case .pending: return Color(hex: "#F5A524") @@ -2771,7 +2788,7 @@ private func ghCIDot(_ ci: CIState) -> Color { } } -private struct GitHubPRRowView: View { +struct GitHubPRRowView: View { let pr: GitHubPR let showCI: Bool var selected: Bool = false @@ -2818,7 +2835,7 @@ private struct GitHubPRRowView: View { } } -private struct GitHubRepoCIRowView: View { +struct GitHubRepoCIRowView: View { let repo: GitHubRepoCI var selected: Bool = false diff --git a/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift index 154450dd14..fcff3522c8 100644 --- a/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift +++ b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift @@ -161,642 +161,4 @@ final class ServiceDetailRunner { } } -// MARK: - The services' APIs - -enum ServiceAPI { - struct Failure: Error { - let status: Int - let message: String - } - - /// Every action the iPhone can ask for. Nothing moves money or sends an email. - static let allowedKinds: Set = [ - "vercel.redeploy", "vercel.promote", "vercel.cancel", - "github.rerun", "github.approve", "github.merge", - "n8n.activate", "n8n.deactivate", "n8n.retry", - ] - - static func prefix(of pillId: String) -> String { - pillId.replacingOccurrences(of: "integration_", with: "") + "." - } - - static func describe(_ error: Error) -> String { - if let failure = error as? Failure { - switch failure.status { - case 401: - if failure.message.localizedCaseInsensitiveContains("restricted") { - // Resend `restricted_api_key`: a Sending-access key can't read emails or domains. - return "This key can only send emails. Use a Full access key in Coucou's Settings on your Mac to see them here." - } - return "The key was refused (401). Check it in Coucou's Settings on your Mac." - case 403: return "Not allowed with this key (403). \(failure.message)" - case 404: return "Not found (404)." - default: return failure.message.isEmpty ? "Error \(failure.status)" : String(failure.message.prefix(160)) - } - } - return error.localizedDescription - } - - // MARK: HTTP - - private static func request(_ urlString: String, method: String = "GET", headers: [String: String], - body: [String: Any]? = nil) async throws -> Any { - guard let url = URL(string: urlString) else { throw Failure(status: 0, message: "Bad address") } - var request = URLRequest(url: url, timeoutInterval: 15) - request.httpMethod = method - for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } - if let body { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - } - let (data, response) = try await URLSession.shared.data(for: request) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200..<300).contains(status) else { - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - let message = (json?["message"] as? String) - ?? ((json?["error"] as? [String: Any])?["message"] as? String) - ?? (json?["error"] as? String) - ?? String(decoding: data.prefix(200), as: UTF8.self) - throw Failure(status: status, message: message) - } - if data.isEmpty { return [String: Any]() } - return try JSONSerialization.jsonObject(with: data) - } - - private static func date(_ value: Any?) -> Date? { - if let ms = value as? Double { return Date(timeIntervalSince1970: ms > 1e11 ? ms / 1000 : ms) } - if let ms = value as? Int { return Date(timeIntervalSince1970: ms > 100_000_000_000 ? Double(ms) / 1000 : Double(ms)) } - guard let text = value as? String else { return nil } - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = fractional.date(from: text) { return date } - let plain = ISO8601DateFormatter() - if let date = plain.date(from: text) { return date } - // Resend: "2024-05-01 10:00:00.123456+00" - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - for format in ["yyyy-MM-dd HH:mm:ss.SSSSSSZ", "yyyy-MM-dd HH:mm:ss.SSSZ", "yyyy-MM-dd HH:mm:ssZ"] { - formatter.dateFormat = format - if let date = formatter.date(from: text.count > 3 && text.hasSuffix("+00") ? text + "00" : text) { return date } - } - return nil - } - - private static func money(_ cents: Int, _ currency: String) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = currency.uppercased() - // Stripe zero-decimal currencies (docs.stripe.com/currencies#zero-decimal). UGX and ISK stay ×100 - // for backward compatibility; HUF and TWD charges are two-decimal. - let zeroDecimal: Set = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", - "vnd", "vuv", "xaf", "xof", "xpf"] - let amount = zeroDecimal.contains(currency.lowercased()) ? Double(cents) : Double(cents) / 100 - return formatter.string(from: NSNumber(value: amount)) ?? "\(amount) \(currency.uppercased())" - } - - // MARK: Reading - - static func detail(for pillId: String) async -> ServiceDetail { - do { - switch pillId { - case "integration_vercel": return try await vercel() - case "integration_github": return try await github() - case "integration_stripe": return try await stripe() - case "integration_resend": return try await resend() - case "integration_calcom": return try await calcom() - case "integration_n8n": return try await n8n() - case "integration_notion": return try await notion() - default: return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: "Not available from the iPhone yet.") - } - } catch { - return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: describe(error)) - } - } - - private static func secret(_ name: String, _ service: String) throws -> String { - guard let value = KeychainStore.shared.get(name), !value.isEmpty else { - throw Failure(status: 0, message: "No \(service) key on your Mac. Add it in Coucou's Settings.") - } - return value - } - - // Vercel: deployments (redeploy, promote to production, cancel) and projects. - private static func vercel() async throws -> ServiceDetail { - let token = try secret("vercel-token", "Vercel") - let headers = ["Authorization": "Bearer \(token)", "Accept": "application/json"] - let deploymentsJSON = try await request("https://api.vercel.com/v6/deployments?limit=20", headers: headers) - let deployments = (deploymentsJSON as? [String: Any])?["deployments"] as? [[String: Any]] ?? [] - let projectsJSON = try? await request("https://api.vercel.com/v9/projects?limit=20", headers: headers) - let projects = (projectsJSON as? [String: Any])?["projects"] as? [[String: Any]] ?? [] - - var items: [DetailItem] = [] - var ready = 0, failed = 0, building = 0 - for d in deployments { - guard let uid = d["uid"] as? String else { continue } - let name = d["name"] as? String ?? "Deployment" - let state = (d["state"] as? String ?? d["readyState"] as? String ?? "").uppercased() - let target = d["target"] as? String - let meta = d["meta"] as? [String: Any] ?? [:] - let message = meta["githubCommitMessage"] as? String ?? "" - let branch = meta["githubCommitRef"] as? String ?? "" - let creator = (d["creator"] as? [String: Any])?["username"] as? String ?? "" - var tone: ServiceTone = .idle - var actions: [ServiceActionDef] = [] - switch state { - case "READY": - ready += 1 - tone = .ok - actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", - target: "\(uid)|\(name)|\(target ?? "")")) - if target != "production" { - actions.append(ServiceActionDef(kind: "vercel.promote", title: "Promote to production", - symbol: "arrow.up.circle", target: "\(uid)|\(name)", - confirm: "A new production build starts from this deployment, using your production environment variables.")) - } - case "ERROR": - failed += 1 - tone = .error - actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", - target: "\(uid)|\(name)|\(target ?? "")")) - case "BUILDING", "QUEUED", "INITIALIZING": - building += 1 - tone = .warning - actions.append(ServiceActionDef(kind: "vercel.cancel", title: "Cancel build", symbol: "xmark.circle", - target: uid, destructive: true, confirm: "Stop this build?")) - case "CANCELED": tone = .idle - default: break - } - let subtitle = [branch, message.split(separator: "\n").first.map(String.init) ?? "", creator] - .filter { !$0.isEmpty }.joined(separator: " · ") - items.append(DetailItem(title: name, subtitle: subtitle.isEmpty ? state.capitalized : subtitle, tone: tone, - date: date(d["created"] ?? d["createdAt"]), - url: (d["url"] as? String).map { "https://\($0)" }, - badge: target == "production" ? "Production" : (state == "READY" ? "Preview" : state.capitalized), - actions: actions)) - } - let projectItems = projects.prefix(20).map { p in - DetailItem(title: p["name"] as? String ?? "Project", - subtitle: (p["framework"] as? String).map { $0.capitalized } ?? "", - date: date(p["updatedAt"])) - } - var sections = [DetailSection(title: "Deployments", items: items)] - if !projectItems.isEmpty { sections.append(DetailSection(title: "Projects · \(projectItems.count)", items: Array(projectItems))) } - return ServiceDetail(pillId: "integration_vercel", - stats: [DetailStat(label: "Ready", value: "\(ready)", tone: .ok), - DetailStat(label: "Building", value: "\(building)", tone: building > 0 ? .warning : .idle), - DetailStat(label: "Failed", value: "\(failed)", tone: failed > 0 ? .error : .idle), - DetailStat(label: "Projects", value: "\(projects.count)")], - sections: sections, fetchedAt: Date()) - } - - // GitHub: your pull requests (merge), reviews asked of you (approve), CI runs (re-run failed jobs). - private static func github() async throws -> ServiceDetail { - let token = try secret("github-token", "GitHub") - let headers = ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28"] - let user = try await request("https://api.github.com/user", headers: headers) as? [String: Any] ?? [:] - let login = user["login"] as? String ?? "" - - func search(_ query: String) async -> [[String: Any]] { - var components = URLComponents(string: "https://api.github.com/search/issues")! - components.queryItems = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "per_page", value: "10"), - URLQueryItem(name: "sort", value: "updated")] - let json = try? await request(components.url!.absoluteString, headers: headers) - return (json as? [String: Any])?["items"] as? [[String: Any]] ?? [] - } - /// "owner/repo#12" from a search result. - func pullRef(_ item: [String: Any]) -> String? { - guard let repoURL = item["repository_url"] as? String, let number = item["number"] as? Int else { return nil } - let parts = repoURL.split(separator: "/").suffix(2).joined(separator: "/") - return "\(parts)#\(number)" - } - - let mine = await search("is:pr is:open author:\(login) archived:false") - let asked = await search("is:pr is:open review-requested:\(login) archived:false") - - let mineItems = mine.compactMap { item -> DetailItem? in - guard let ref = pullRef(item) else { return nil } - let draft = item["draft"] as? Bool ?? false - return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: ref, - tone: draft ? .idle : .info, date: date(item["updated_at"]), - url: item["html_url"] as? String, badge: draft ? "Draft" : nil, - actions: draft ? [] : [ServiceActionDef(kind: "github.merge", title: "Squash and merge", - symbol: "arrow.triangle.merge", target: ref, - confirm: "Merge \(ref) into its base branch?")]) - } - let askedItems = asked.compactMap { item -> DetailItem? in - guard let ref = pullRef(item) else { return nil } - let author = (item["user"] as? [String: Any])?["login"] as? String ?? "" - return DetailItem(title: item["title"] as? String ?? "Pull request", - subtitle: author.isEmpty ? ref : "\(ref) · \(author)", tone: .warning, - date: date(item["updated_at"]), url: item["html_url"] as? String, - actions: [ServiceActionDef(kind: "github.approve", title: "Approve", symbol: "checkmark.seal", - target: ref)]) - } - - // CI on the repos you pushed to last. - let reposJSON = try? await request("https://api.github.com/user/repos?sort=pushed&per_page=5&affiliation=owner,collaborator", - headers: headers) - let repos = (reposJSON as? [[String: Any]] ?? []).compactMap { $0["full_name"] as? String } - var runItems: [DetailItem] = [] - var failing = 0 - for repo in repos { - let runsJSON = try? await request("https://api.github.com/repos/\(repo)/actions/runs?per_page=3", headers: headers) - let runs = (runsJSON as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? [] - for run in runs { - guard let id = run["id"] as? Int else { continue } - let status = run["status"] as? String ?? "" - let conclusion = run["conclusion"] as? String ?? "" - var tone: ServiceTone = .idle - var actions: [ServiceActionDef] = [] - if status != "completed" { - tone = .warning - } else if conclusion == "success" { - tone = .ok - } else if conclusion == "failure" || conclusion == "timed_out" { - tone = .error - failing += 1 - actions.append(ServiceActionDef(kind: "github.rerun", title: "Re-run failed jobs", - symbol: "arrow.clockwise", target: "\(repo)/\(id)")) - } - let label = status == "completed" ? conclusion.replacingOccurrences(of: "_", with: " ") : status.replacingOccurrences(of: "_", with: " ") - runItems.append(DetailItem(title: run["name"] as? String ?? "Workflow", - subtitle: "\(repo) · \(run["head_branch"] as? String ?? "")", - tone: tone, date: date(run["created_at"]), - url: run["html_url"] as? String, badge: label.capitalized, actions: actions)) - } - } - - var sections: [DetailSection] = [] - if !askedItems.isEmpty { sections.append(DetailSection(title: "Reviews asked of you", items: askedItems)) } - sections.append(DetailSection(title: "Your pull requests", items: mineItems, - footer: mineItems.isEmpty ? "No open pull request." : nil)) - if !runItems.isEmpty { sections.append(DetailSection(title: "CI on your latest repos", items: runItems)) } - return ServiceDetail(pillId: "integration_github", - stats: [DetailStat(label: "Your PRs", value: "\(mineItems.count)", tone: .info), - DetailStat(label: "Reviews", value: "\(askedItems.count)", tone: askedItems.isEmpty ? .idle : .warning), - DetailStat(label: "CI failing", value: "\(failing)", tone: failing > 0 ? .error : .ok)], - sections: sections, fetchedAt: Date()) - } - - // Stripe: balance, payments, payouts, subscriptions. Read only. - private static func stripe() async throws -> ServiceDetail { - let key = try secret("stripe-api-key", "Stripe") - let headers = ["Authorization": "Bearer \(key)"] - let balance = try await request("https://api.stripe.com/v1/balance", headers: headers) as? [String: Any] ?? [:] - // One entry per currency in each list, in no set order: pair them by currency. - let availableList = balance["available"] as? [[String: Any]] ?? [] - let pendingList = balance["pending"] as? [[String: Any]] ?? [] - let currency = ((availableList.first ?? pendingList.first)?["currency"] as? String ?? "eur").lowercased() - func amount(_ list: [[String: Any]]) -> Int { - list.first { ($0["currency"] as? String)?.lowercased() == currency }?["amount"] as? Int ?? 0 - } - let available = amount(availableList) - let pending = amount(pendingList) - - /// A list, or why it couldn't be read (shown instead of a made-up zero). - func list(_ path: String) async -> ([[String: Any]], [String: Any], String?) { - do { - let json = try await request("https://api.stripe.com/v1/\(path)", headers: headers) as? [String: Any] ?? [:] - return (json["data"] as? [[String: Any]] ?? [], json, nil) - } catch { - return ([], [:], describe(error)) - } - } - - let (charges, _, chargesError) = await list("charges?limit=15") - let chargeItems = charges.map { c -> DetailItem in - let status = c["status"] as? String ?? "" - let refunded = c["refunded"] as? Bool ?? false - let partlyRefunded = !refunded && (c["amount_refunded"] as? Int ?? 0) > 0 - let uncaptured = !refunded && status == "succeeded" && (c["captured"] as? Bool) == false - let who = ((c["billing_details"] as? [String: Any])?["name"] as? String) - ?? (c["receipt_email"] as? String) ?? (c["description"] as? String) ?? "" - let tone: ServiceTone = refunded ? .idle - : (uncaptured || partlyRefunded) ? .warning - : status == "succeeded" ? .ok : status == "failed" ? .error : .warning - let badge = refunded ? "Refunded" : uncaptured ? "Uncaptured" - : partlyRefunded ? "Partly refunded" : status.capitalized - return DetailItem(title: money(c["amount"] as? Int ?? 0, c["currency"] as? String ?? currency), - subtitle: who, tone: tone, date: date(c["created"]), badge: badge) - } - let (payouts, _, payoutsError) = await list("payouts?limit=5") - let payoutItems = payouts.map { p in - DetailItem(title: money(p["amount"] as? Int ?? 0, p["currency"] as? String ?? currency), - subtitle: "Arrives", tone: (p["status"] as? String) == "failed" ? .error : .info, - date: date(p["arrival_date"]), badge: (p["status"] as? String)?.replacingOccurrences(of: "_", with: " ").capitalized) - } - - // Active subscriptions, every page (up to 1 000). - var subs: [[String: Any]] = [] - var moreSubs = false - var subsError: String? - var cursor: String? - for _ in 0..<10 { - let (page, json, error) = await list("subscriptions?status=active&limit=100" + (cursor.map { "&starting_after=\($0)" } ?? "")) - if let error { subsError = error; break } - subs += page - moreSubs = json["has_more"] as? Bool ?? false - guard moreSubs, let last = subs.last?["id"] as? String else { break } - cursor = last - } - // Estimated monthly revenue, one total per currency. Metered and tiered - // prices have no fixed amount per period and discounts aren't applied. - var monthlyByCurrency: [String: Double] = [:] - for sub in subs { - let subCurrency = (sub["currency"] as? String)?.lowercased() - let items = (sub["items"] as? [String: Any])?["data"] as? [[String: Any]] ?? [] - for item in items { - let price = item["price"] as? [String: Any] ?? [:] - let recurring = price["recurring"] as? [String: Any] ?? [:] - if recurring["usage_type"] as? String == "metered" || price["billing_scheme"] as? String == "tiered" { continue } - let perUnit = (price["unit_amount"] as? Int).map { Double($0) } - ?? Double(price["unit_amount_decimal"] as? String ?? "") ?? 0 - let unit = perUnit * Double(item["quantity"] as? Int ?? 1) - let count = Double(recurring["interval_count"] as? Int ?? 1) - let perMonth: Double - switch recurring["interval"] as? String { - case "year": perMonth = unit / (12 * count) - case "week": perMonth = unit * 4.345 / count - case "day": perMonth = unit * 30.4 / count - default: perMonth = unit / count - } - let itemCurrency = subCurrency ?? (price["currency"] as? String)?.lowercased() ?? currency - monthlyByCurrency[itemCurrency, default: 0] += perMonth - } - } - let largest = monthlyByCurrency.max { $0.value < $1.value } - let mrrCurrency = monthlyByCurrency[currency] != nil ? currency : (largest?.key ?? currency) - let monthly = monthlyByCurrency[mrrCurrency] ?? 0 - - var sections = [DetailSection(title: "Payments", items: chargeItems, footer: chargesError)] - if !payoutItems.isEmpty || payoutsError != nil { - sections.append(DetailSection(title: "Payouts", items: payoutItems, footer: payoutsError)) - } - if let subsError { sections.append(DetailSection(title: "Subscriptions", items: [], footer: subsError)) } - return ServiceDetail(pillId: "integration_stripe", - stats: [DetailStat(label: "Available", value: money(available, currency), tone: .ok), - DetailStat(label: "Pending", value: money(pending, currency)), - DetailStat(label: "Subscriptions", - value: subsError != nil ? "—" : (moreSubs ? "\(subs.count)+" : "\(subs.count)"), - tone: .info), - DetailStat(label: "Est. MRR", - value: subsError != nil ? "—" : money(Int(monthly.rounded()), mrrCurrency), - tone: .info)], - sections: sections, fetchedAt: Date()) - } - - // Resend: the latest emails and how they went, and the domains. Read only. - private static func resend() async throws -> ServiceDetail { - let key = try secret("resend-api-key", "Resend") - let headers = ["Authorization": "Bearer \(key)"] - let emailsJSON = try await request("https://api.resend.com/emails?limit=20", headers: headers) - let emails = (emailsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] - var bounced = 0, delivered = 0 - let emailItems = emails.map { e -> DetailItem in - let event = e["last_event"] as? String ?? "sent" - let tone: ServiceTone - switch event { - case "delivered", "opened", "clicked": tone = .ok; delivered += 1 - case "bounced", "complained", "failed", "suppressed": tone = .error; bounced += 1 - case "delivery_delayed": tone = .warning - default: tone = .info - } - let to = (e["to"] as? [String])?.joined(separator: ", ") ?? "" - return DetailItem(title: e["subject"] as? String ?? "(no subject)", subtitle: to, tone: tone, - date: date(e["created_at"]), badge: event.replacingOccurrences(of: "_", with: " ").capitalized) - } - let domainsJSON = try? await request("https://api.resend.com/domains", headers: headers) - let domains = (domainsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] - let domainItems = domains.map { d in - let status = d["status"] as? String ?? "" - return DetailItem(title: d["name"] as? String ?? "Domain", subtitle: d["region"] as? String ?? "", - tone: status == "verified" ? .ok : (status == "failed" ? .error : .warning), - badge: status.replacingOccurrences(of: "_", with: " ").capitalized) - } - var sections = [DetailSection(title: "Latest emails", items: emailItems)] - if !domainItems.isEmpty { sections.append(DetailSection(title: "Domains", items: domainItems)) } - return ServiceDetail(pillId: "integration_resend", - stats: [DetailStat(label: "Delivered", value: "\(delivered)", tone: .ok), - DetailStat(label: "Bounced", value: "\(bounced)", tone: bounced > 0 ? .error : .idle), - DetailStat(label: "Domains", value: "\(domains.count)")], - sections: sections, fetchedAt: Date()) - } - - // Cal.com: upcoming bookings. Read only: cancelling emails the guest and can refund or charge them. - private static func calcom() async throws -> ServiceDetail { - let key = try secret("calcom-api-key", "Cal.com") - let headers = ["Authorization": "Bearer \(key)", "cal-api-version": "2024-08-13"] - let iso = ISO8601DateFormatter() - let now = Date() - var components = URLComponents(string: "https://api.cal.com/v2/bookings")! - components.queryItems = [URLQueryItem(name: "status", value: "upcoming"), - URLQueryItem(name: "afterStart", value: iso.string(from: now)), - URLQueryItem(name: "take", value: "30")] - let json = try await request(components.url!.absoluteString, headers: headers) - let bookings = (json as? [String: Any])?["data"] as? [[String: Any]] ?? [] - let calendar = Calendar.current - var today = 0, week = 0 - let items = bookings.compactMap { b -> DetailItem? in - guard let start = date(b["start"] ?? b["startTime"]) else { return nil } - if calendar.isDateInToday(start) { today += 1 } - if start.timeIntervalSince(now) < 7 * 24 * 3600 { week += 1 } - let attendee = (b["attendees"] as? [[String: Any]])?.first - let who = attendee?["name"] as? String ?? attendee?["email"] as? String ?? "" - let when = start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) - return DetailItem(title: b["title"] as? String ?? "Meeting", - subtitle: who.isEmpty ? when : "\(when) · \(who)", - tone: calendar.isDateInToday(start) ? .warning : .info, - date: start, url: (b["meetingUrl"] as? String) ?? (b["location"] as? String).flatMap { $0.hasPrefix("https://") ? $0 : nil }, - badge: (b["status"] as? String)?.capitalized) - } - return ServiceDetail(pillId: "integration_calcom", - stats: [DetailStat(label: "Today", value: "\(today)", tone: today > 0 ? .warning : .idle), - DetailStat(label: "Next 7 days", value: "\(week)", tone: .info), - DetailStat(label: "Upcoming", value: "\(items.count)")], - sections: [DetailSection(title: "Upcoming bookings", items: items, - footer: items.isEmpty ? "Nothing booked." : nil)], - fetchedAt: Date()) - } - - // n8n: workflows (activate, deactivate) and executions (retry a failed one). - private static func n8n() async throws -> ServiceDetail { - let key = try secret("n8n-api-key", "n8n") - let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] - // The list comes in pages (at most 250, then `cursor` = the previous `nextCursor`): read - // them all so the counts are right. - var workflows: [[String: Any]] = [] - var cursor: String? - repeat { - var url = "\(base)/api/v1/workflows?limit=250" - if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { - url += "&cursor=\(encoded)" - } - let page = try await request(url, headers: headers) as? [String: Any] - workflows += page?["data"] as? [[String: Any]] ?? [] - cursor = page?["nextCursor"] as? String - } while !(cursor ?? "").isEmpty && workflows.count < 5_000 - var names: [String: String] = [:] - var active = 0 - let workflowItems = workflows.compactMap { w -> DetailItem? in - guard let id = (w["id"] as? String) ?? (w["id"] as? Int).map(String.init) else { return nil } - let name = w["name"] as? String ?? "Workflow" - names[id] = name - // Archived workflows are listed but can't be turned on (n8n answers 400). - if w["isArchived"] as? Bool ?? false { return nil } - let isActive = w["active"] as? Bool ?? false - if isActive { active += 1 } - return DetailItem(title: name, tone: isActive ? .ok : .idle, date: date(w["updatedAt"]), - url: "\(base)/workflow/\(id)", badge: isActive ? "Active" : "Off", - actions: [isActive - ? ServiceActionDef(kind: "n8n.deactivate", title: "Turn off", symbol: "pause.circle", - target: id, destructive: true, confirm: "Stop \(name) from running?") - : ServiceActionDef(kind: "n8n.activate", title: "Turn on", symbol: "play.circle", target: id)]) - } - let executionsJSON = try? await request("\(base)/api/v1/executions?limit=15", headers: headers) - let executions = (executionsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] - var failed = 0 - let executionItems = executions.compactMap { e -> DetailItem? in - guard let id = (e["id"] as? String) ?? (e["id"] as? Int).map(String.init) else { return nil } - let workflowId = (e["workflowId"] as? String) ?? (e["workflowId"] as? Int).map(String.init) ?? "" - let status = e["status"] as? String ?? ((e["finished"] as? Bool ?? false) ? "success" : "error") - let isFailure = status == "error" || status == "crashed" || status == "failed" - if isFailure { failed += 1 } - return DetailItem(title: names[workflowId] ?? "Workflow \(workflowId)", subtitle: "Execution \(id)", - tone: isFailure ? .error : (status == "success" ? .ok : .warning), - date: date(e["startedAt"]), url: "\(base)/workflow/\(workflowId)/executions/\(id)", - badge: status.capitalized, - actions: isFailure ? [ServiceActionDef(kind: "n8n.retry", title: "Retry", symbol: "arrow.clockwise", target: id)] : []) - } - return ServiceDetail(pillId: "integration_n8n", - stats: [DetailStat(label: "Workflows", value: "\(workflowItems.count)"), - DetailStat(label: "Active", value: "\(active)", tone: .ok), - DetailStat(label: "Failed runs", value: "\(failed)", tone: failed > 0 ? .error : .idle)], - sections: [DetailSection(title: "Latest runs", items: executionItems), - DetailSection(title: "Workflows", - items: Array(workflowItems.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }.prefix(50)))], - fetchedAt: Date()) - } - - // Notion: the pages and databases edited last. Read only. - private static func notion() async throws -> ServiceDetail { - let key = try secret("notion-api-key", "Notion") - let headers = ["Authorization": "Bearer \(key)", "Notion-Version": "2022-06-28"] - let json = try await request("https://api.notion.com/v1/search", method: "POST", headers: headers, - body: ["sort": ["direction": "descending", "timestamp": "last_edited_time"], "page_size": 25]) - let results = (json as? [String: Any])?["results"] as? [[String: Any]] ?? [] - var pages: [DetailItem] = [] - var databases: [DetailItem] = [] - for object in results { - let kind = object["object"] as? String ?? "page" - var title = "" - if kind == "database" { - title = ((object["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" - } else if let properties = object["properties"] as? [String: Any] { - for case let property as [String: Any] in properties.values where property["type"] as? String == "title" { - title = ((property["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" - } - } - var emoji = "" - if let icon = object["icon"] as? [String: Any], icon["type"] as? String == "emoji" { emoji = (icon["emoji"] as? String ?? "") + " " } - let item = DetailItem(title: emoji + (title.isEmpty ? "Untitled" : title), - date: date(object["last_edited_time"]), url: object["url"] as? String, - badge: (object["archived"] as? Bool ?? false) ? "Archived" : nil) - if kind == "database" { databases.append(item) } else { pages.append(item) } - } - var sections = [DetailSection(title: "Edited lately", items: pages)] - if !databases.isEmpty { sections.append(DetailSection(title: "Databases", items: databases)) } - let editedToday = results.filter { date($0["last_edited_time"]).map(Calendar.current.isDateInToday) ?? false }.count - return ServiceDetail(pillId: "integration_notion", - stats: [DetailStat(label: "Edited today", value: "\(editedToday)", tone: editedToday > 0 ? .info : .idle), - DetailStat(label: "Pages", value: "\(pages.count)"), - DetailStat(label: "Databases", value: "\(databases.count)")], - sections: sections, fetchedAt: Date()) - } - - // MARK: Acting - - /// Runs one action. Returns a short line for the iPhone. - static func perform(kind: String, target: String) async throws -> String { - switch kind { - case "vercel.redeploy": - let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown deployment") } - var body: [String: Any] = ["name": parts[1], "deploymentId": parts[0]] - if !parts[2].isEmpty { body["target"] = parts[2] } - let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", - headers: try vercelHeaders(), body: body) - let url = (json as? [String: Any])?["url"] as? String - return url.map { "Building \($0)" } ?? "New deployment started" - case "vercel.promote": - // Vercel only promotes production builds in place: a preview is rebuilt for - // production, as `vercel promote` does. - let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count == 2 else { throw Failure(status: 0, message: "Unknown deployment") } - let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", - headers: try vercelHeaders(), - body: ["deploymentId": parts[0], "name": parts[1], "target": "production", - "meta": ["action": "promote"]]) - let url = (json as? [String: Any])?["url"] as? String - return url.map { "Building \($0) for production" } ?? "Production build started" - case "vercel.cancel": - _ = try await request("https://api.vercel.com/v12/deployments/\(target)/cancel", method: "PATCH", - headers: try vercelHeaders()) - return "Build canceled" - case "github.rerun": - // "owner/repo/123" - let parts = target.split(separator: "/").map(String.init) - guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown run") } - _ = try await request("https://api.github.com/repos/\(parts[0])/\(parts[1])/actions/runs/\(parts[2])/rerun-failed-jobs", - method: "POST", headers: try githubHeaders()) - return "Failed jobs started again" - case "github.approve", "github.merge": - // "owner/repo#12" - let pieces = target.split(separator: "#").map(String.init) - guard pieces.count == 2 else { throw Failure(status: 0, message: "Unknown pull request") } - if kind == "github.approve" { - _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/reviews", method: "POST", - headers: try githubHeaders(), body: ["event": "APPROVE"]) - return "Approved" - } - _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/merge", method: "PUT", - headers: try githubHeaders(), body: ["merge_method": "squash"]) - return "Merged" - case "n8n.activate", "n8n.deactivate", "n8n.retry": - let key = try secret("n8n-api-key", "n8n") - let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] - switch kind { - case "n8n.activate": - _ = try await request("\(base)/api/v1/workflows/\(target)/activate", method: "POST", headers: headers) - return "Turned on" - case "n8n.deactivate": - _ = try await request("\(base)/api/v1/workflows/\(target)/deactivate", method: "POST", headers: headers) - return "Turned off" - default: - do { - _ = try await request("\(base)/api/v1/executions/\(target)/retry", method: "POST", headers: headers) - } catch let failure as Failure where failure.status == 404 || failure.status == 405 { - // POST /api/v1/executions/{id}/retry only exists from n8n 1.112.0. - throw Failure(status: 0, message: "Couldn't retry this run. It may have been deleted, or your n8n is older than 1.112, which is needed to retry from the iPhone.") - } - return "Running again" - } - default: - throw Failure(status: 0, message: "This action isn't available") - } - } - - private static func vercelHeaders() throws -> [String: String] { - let token = try secret("vercel-token", "Vercel") - return ["Authorization": "Bearer \(token)", "Accept": "application/json"] - } - - private static func githubHeaders() throws -> [String: String] { - let token = try secret("github-token", "GitHub") - return ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28"] - } -} #endif diff --git a/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift b/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift index 7bb6ab9d37..7384e6db07 100644 --- a/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift +++ b/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift @@ -269,13 +269,14 @@ enum ServiceSnapshots { let deployments = filter.isEmpty ? s.vercelDeployments : s.vercelDeployments.filter { filter.contains($0.projectName) } guard let latest = deployments.first else { return nil } func tone(_ d: VercelDeployment) -> ServiceTone { - d.isSuccess ? .ok : (d.state == "CANCELED" ? .warning : .error) + d.isSuccess ? .ok : ((d.state == "CANCELED" || d.isBuilding) ? .warning : .error) } let branch = latest.branch.map { " · \($0)" } ?? "" let reason: String switch latest.state { case "READY": reason = "Last deploy is live: \(latest.projectName)\(branch)" case "CANCELED": reason = "Last deploy was canceled: \(latest.projectName)\(branch)" + case "BUILDING", "QUEUED", "INITIALIZING": reason = "Building: \(latest.projectName)\(branch)" default: reason = "Last deploy failed: \(latest.projectName)\(branch)" } let items = deployments.prefix(10).map { d in diff --git a/NotchBuddy/Sources/App/ServiceAPI.swift b/NotchBuddy/Sources/App/ServiceAPI.swift new file mode 100644 index 0000000000..48a6643df7 --- /dev/null +++ b/NotchBuddy/Sources/App/ServiceAPI.swift @@ -0,0 +1,685 @@ +import AppKit + +// MARK: - The services' APIs + +enum ServiceAPI { + struct Failure: Error { + let status: Int + let message: String + } + + /// Every action the iPhone can ask for. Nothing moves money or sends an email. + static let allowedKinds: Set = [ + "vercel.redeploy", "vercel.promote", "vercel.cancel", + "github.rerun", "github.approve", "github.merge", + "n8n.activate", "n8n.deactivate", "n8n.retry", + ] + + static func prefix(of pillId: String) -> String { + pillId.replacingOccurrences(of: "integration_", with: "") + "." + } + + static func describe(_ error: Error) -> String { + if let failure = error as? Failure { + switch failure.status { + case 401: + if failure.message.localizedCaseInsensitiveContains("restricted") { + // Resend `restricted_api_key`: a Sending-access key can't read emails or domains. + return "This key can only send emails. Use a Full access key in Coucou's Settings on your Mac to see them here." + } + return "The key was refused (401). Check it in Coucou's Settings on your Mac." + case 403: return "Not allowed with this key (403). \(failure.message)" + case 404: return "Not found (404)." + default: return failure.message.isEmpty ? "Error \(failure.status)" : String(failure.message.prefix(160)) + } + } + return error.localizedDescription + } + + // MARK: HTTP + + private static func request(_ urlString: String, method: String = "GET", headers: [String: String], + body: [String: Any]? = nil) async throws -> Any { + guard let url = URL(string: urlString) else { throw Failure(status: 0, message: "Bad address") } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = method + for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let message = (json?["message"] as? String) + ?? ((json?["error"] as? [String: Any])?["message"] as? String) + ?? (json?["error"] as? String) + ?? String(decoding: data.prefix(200), as: UTF8.self) + throw Failure(status: status, message: message) + } + if data.isEmpty { return [String: Any]() } + return try JSONSerialization.jsonObject(with: data) + } + + private static func date(_ value: Any?) -> Date? { + if let ms = value as? Double { return Date(timeIntervalSince1970: ms > 1e11 ? ms / 1000 : ms) } + if let ms = value as? Int { return Date(timeIntervalSince1970: ms > 100_000_000_000 ? Double(ms) / 1000 : Double(ms)) } + guard let text = value as? String else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: text) { return date } + let plain = ISO8601DateFormatter() + if let date = plain.date(from: text) { return date } + // Resend: "2024-05-01 10:00:00.123456+00" + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd HH:mm:ss.SSSSSSZ", "yyyy-MM-dd HH:mm:ss.SSSZ", "yyyy-MM-dd HH:mm:ssZ"] { + formatter.dateFormat = format + if let date = formatter.date(from: text.count > 3 && text.hasSuffix("+00") ? text + "00" : text) { return date } + } + return nil + } + + private static func money(_ cents: Int, _ currency: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency.uppercased() + // Stripe zero-decimal currencies (docs.stripe.com/currencies#zero-decimal). UGX and ISK stay ×100 + // for backward compatibility; HUF and TWD charges are two-decimal. + let zeroDecimal: Set = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", + "vnd", "vuv", "xaf", "xof", "xpf"] + let amount = zeroDecimal.contains(currency.lowercased()) ? Double(cents) : Double(cents) / 100 + return formatter.string(from: NSNumber(value: amount)) ?? "\(amount) \(currency.uppercased())" + } + + // MARK: Reading + + static func detail(for pillId: String) async -> ServiceDetail { + do { + switch pillId { + case "integration_vercel": return try await vercel() + case "integration_github": return try await github() + case "integration_stripe": return try await stripe() + case "integration_resend": return try await resend() + case "integration_calcom": return try await calcom() + case "integration_n8n": return try await n8n() + case "integration_notion": return try await notion() + default: return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: "Not available from the iPhone yet.") + } + } catch { + return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: describe(error)) + } + } + + private static func secret(_ name: String, _ service: String) throws -> String { + guard let value = KeychainStore.shared.get(name), !value.isEmpty else { + throw Failure(status: 0, message: "No \(service) key on your Mac. Add it in Coucou's Settings.") + } + return value + } + + // Vercel: deployments (redeploy, promote to production, cancel) and projects. + private static func vercel() async throws -> ServiceDetail { + let token = try secret("vercel-token", "Vercel") + let headers = ["Authorization": "Bearer \(token)", "Accept": "application/json"] + let deploymentsJSON = try await request(VercelAPI.url("https://api.vercel.com/v6/deployments?limit=20"), headers: headers) + let deployments = (deploymentsJSON as? [String: Any])?["deployments"] as? [[String: Any]] ?? [] + let projectsJSON = try? await request(VercelAPI.url("https://api.vercel.com/v9/projects?limit=20"), headers: headers) + let projects = (projectsJSON as? [String: Any])?["projects"] as? [[String: Any]] ?? [] + + var items: [DetailItem] = [] + var ready = 0, failed = 0, building = 0 + for d in deployments { + guard let uid = d["uid"] as? String else { continue } + let name = d["name"] as? String ?? "Deployment" + let state = (d["state"] as? String ?? d["readyState"] as? String ?? "").uppercased() + let target = d["target"] as? String + let meta = d["meta"] as? [String: Any] ?? [:] + let message = meta["githubCommitMessage"] as? String ?? "" + let branch = meta["githubCommitRef"] as? String ?? "" + let creator = (d["creator"] as? [String: Any])?["username"] as? String ?? "" + var tone: ServiceTone = .idle + var actions: [ServiceActionDef] = [] + switch state { + case "READY": + ready += 1 + tone = .ok + actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", + target: "\(uid)|\(name)|\(target ?? "")")) + if target != "production" { + actions.append(ServiceActionDef(kind: "vercel.promote", title: "Promote to production", + symbol: "arrow.up.circle", target: "\(uid)|\(name)", + confirm: "A new production build starts from this deployment, using your production environment variables.")) + } + case "ERROR": + failed += 1 + tone = .error + actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", + target: "\(uid)|\(name)|\(target ?? "")")) + case "BUILDING", "QUEUED", "INITIALIZING": + building += 1 + tone = .warning + actions.append(ServiceActionDef(kind: "vercel.cancel", title: "Cancel build", symbol: "xmark.circle", + target: uid, destructive: true, confirm: "Stop this build?")) + case "CANCELED": tone = .idle + default: break + } + let subtitle = [branch, message.split(separator: "\n").first.map(String.init) ?? "", creator] + .filter { !$0.isEmpty }.joined(separator: " · ") + items.append(DetailItem(title: name, subtitle: subtitle.isEmpty ? state.capitalized : subtitle, tone: tone, + date: date(d["created"] ?? d["createdAt"]), + url: (d["url"] as? String).map { "https://\($0)" }, + badge: target == "production" ? "Production" : (state == "READY" ? "Preview" : state.capitalized), + actions: actions)) + } + let projectItems = projects.prefix(20).map { p in + DetailItem(title: p["name"] as? String ?? "Project", + subtitle: (p["framework"] as? String).map { $0.capitalized } ?? "", + date: date(p["updatedAt"])) + } + var sections = [DetailSection(title: "Deployments", items: items)] + if !projectItems.isEmpty { sections.append(DetailSection(title: "Projects · \(projectItems.count)", items: Array(projectItems))) } + return ServiceDetail(pillId: "integration_vercel", + stats: [DetailStat(label: "Ready", value: "\(ready)", tone: .ok), + DetailStat(label: "Building", value: "\(building)", tone: building > 0 ? .warning : .idle), + DetailStat(label: "Failed", value: "\(failed)", tone: failed > 0 ? .error : .idle), + DetailStat(label: "Projects", value: "\(projects.count)")], + sections: sections, fetchedAt: Date()) + } + + // GitHub: your pull requests (merge), reviews asked of you (approve), CI runs (re-run failed jobs). + private static func github() async throws -> ServiceDetail { + let token = try secret("github-token", "GitHub") + let headers = ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"] + let user = try await request("https://api.github.com/user", headers: headers) as? [String: Any] ?? [:] + let login = user["login"] as? String ?? "" + + func search(_ query: String) async -> [[String: Any]] { + var components = URLComponents(string: "https://api.github.com/search/issues")! + components.queryItems = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "per_page", value: "10"), + URLQueryItem(name: "sort", value: "updated")] + let json = try? await request(components.url!.absoluteString, headers: headers) + return (json as? [String: Any])?["items"] as? [[String: Any]] ?? [] + } + /// "owner/repo#12" from a search result. + func pullRef(_ item: [String: Any]) -> String? { + guard let repoURL = item["repository_url"] as? String, let number = item["number"] as? Int else { return nil } + let parts = repoURL.split(separator: "/").suffix(2).joined(separator: "/") + return "\(parts)#\(number)" + } + + let mine = await search("is:pr is:open author:\(login) archived:false") + let asked = await search("is:pr is:open review-requested:\(login) archived:false") + + let mineItems = mine.compactMap { item -> DetailItem? in + guard let ref = pullRef(item) else { return nil } + let draft = item["draft"] as? Bool ?? false + return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: ref, + tone: draft ? .idle : .info, date: date(item["updated_at"]), + url: item["html_url"] as? String, badge: draft ? "Draft" : nil, + actions: draft ? [] : [ServiceActionDef(kind: "github.merge", title: "Squash and merge", + symbol: "arrow.triangle.merge", target: ref, + confirm: "Merge \(ref) into its base branch?")]) + } + let askedItems = asked.compactMap { item -> DetailItem? in + guard let ref = pullRef(item) else { return nil } + let author = (item["user"] as? [String: Any])?["login"] as? String ?? "" + return DetailItem(title: item["title"] as? String ?? "Pull request", + subtitle: author.isEmpty ? ref : "\(ref) · \(author)", tone: .warning, + date: date(item["updated_at"]), url: item["html_url"] as? String, + actions: [ServiceActionDef(kind: "github.approve", title: "Approve", symbol: "checkmark.seal", + target: ref)]) + } + + // CI on the repos you pushed to last. + let reposJSON = try? await request("https://api.github.com/user/repos?sort=pushed&per_page=5&affiliation=owner,collaborator", + headers: headers) + let repos = (reposJSON as? [[String: Any]] ?? []).compactMap { $0["full_name"] as? String } + var runItems: [DetailItem] = [] + var failing = 0 + for repo in repos { + let runsJSON = try? await request("https://api.github.com/repos/\(repo)/actions/runs?per_page=3", headers: headers) + let runs = (runsJSON as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? [] + for run in runs { + guard let id = run["id"] as? Int else { continue } + let status = run["status"] as? String ?? "" + let conclusion = run["conclusion"] as? String ?? "" + var tone: ServiceTone = .idle + var actions: [ServiceActionDef] = [] + if status != "completed" { + tone = .warning + } else if conclusion == "success" { + tone = .ok + } else if conclusion == "failure" || conclusion == "timed_out" { + tone = .error + failing += 1 + actions.append(ServiceActionDef(kind: "github.rerun", title: "Re-run failed jobs", + symbol: "arrow.clockwise", target: "\(repo)/\(id)")) + } + let label = status == "completed" ? conclusion.replacingOccurrences(of: "_", with: " ") : status.replacingOccurrences(of: "_", with: " ") + runItems.append(DetailItem(title: run["name"] as? String ?? "Workflow", + subtitle: "\(repo) · \(run["head_branch"] as? String ?? "")", + tone: tone, date: date(run["created_at"]), + url: run["html_url"] as? String, badge: label.capitalized, actions: actions)) + } + } + + var sections: [DetailSection] = [] + if !askedItems.isEmpty { sections.append(DetailSection(title: "Reviews asked of you", items: askedItems)) } + sections.append(DetailSection(title: "Your pull requests", items: mineItems, + footer: mineItems.isEmpty ? "No open pull request." : nil)) + if !runItems.isEmpty { sections.append(DetailSection(title: "CI on your latest repos", items: runItems)) } + return ServiceDetail(pillId: "integration_github", + stats: [DetailStat(label: "Your PRs", value: "\(mineItems.count)", tone: .info), + DetailStat(label: "Reviews", value: "\(askedItems.count)", tone: askedItems.isEmpty ? .idle : .warning), + DetailStat(label: "CI failing", value: "\(failing)", tone: failing > 0 ? .error : .ok)], + sections: sections, fetchedAt: Date()) + } + + // Stripe: balance, payments, payouts, subscriptions. Read only. + private static func stripe() async throws -> ServiceDetail { + let key = try secret("stripe-api-key", "Stripe") + let headers = ["Authorization": "Bearer \(key)"] + let balance = try await request("https://api.stripe.com/v1/balance", headers: headers) as? [String: Any] ?? [:] + // One entry per currency in each list, in no set order: pair them by currency. + let availableList = balance["available"] as? [[String: Any]] ?? [] + let pendingList = balance["pending"] as? [[String: Any]] ?? [] + let currency = ((availableList.first ?? pendingList.first)?["currency"] as? String ?? "eur").lowercased() + func amount(_ list: [[String: Any]]) -> Int { + list.first { ($0["currency"] as? String)?.lowercased() == currency }?["amount"] as? Int ?? 0 + } + let available = amount(availableList) + let pending = amount(pendingList) + + /// A list, or why it couldn't be read (shown instead of a made-up zero). + func list(_ path: String) async -> ([[String: Any]], [String: Any], String?) { + do { + let json = try await request("https://api.stripe.com/v1/\(path)", headers: headers) as? [String: Any] ?? [:] + return (json["data"] as? [[String: Any]] ?? [], json, nil) + } catch { + return ([], [:], describe(error)) + } + } + + let (charges, _, chargesError) = await list("charges?limit=15") + let chargeItems = charges.map { c -> DetailItem in + let status = c["status"] as? String ?? "" + let refunded = c["refunded"] as? Bool ?? false + let partlyRefunded = !refunded && (c["amount_refunded"] as? Int ?? 0) > 0 + let uncaptured = !refunded && status == "succeeded" && (c["captured"] as? Bool) == false + let who = ((c["billing_details"] as? [String: Any])?["name"] as? String) + ?? (c["receipt_email"] as? String) ?? (c["description"] as? String) ?? "" + let tone: ServiceTone = refunded ? .idle + : (uncaptured || partlyRefunded) ? .warning + : status == "succeeded" ? .ok : status == "failed" ? .error : .warning + let badge = refunded ? "Refunded" : uncaptured ? "Uncaptured" + : partlyRefunded ? "Partly refunded" : status.capitalized + return DetailItem(title: money(c["amount"] as? Int ?? 0, c["currency"] as? String ?? currency), + subtitle: who, tone: tone, date: date(c["created"]), badge: badge) + } + let (payouts, _, payoutsError) = await list("payouts?limit=5") + let payoutItems = payouts.map { p in + DetailItem(title: money(p["amount"] as? Int ?? 0, p["currency"] as? String ?? currency), + subtitle: "Arrives", tone: (p["status"] as? String) == "failed" ? .error : .info, + date: date(p["arrival_date"]), badge: (p["status"] as? String)?.replacingOccurrences(of: "_", with: " ").capitalized) + } + + // Active subscriptions, every page (up to 1 000). + var subs: [[String: Any]] = [] + var moreSubs = false + var subsError: String? + var cursor: String? + for _ in 0..<10 { + let (page, json, error) = await list("subscriptions?status=active&limit=100" + (cursor.map { "&starting_after=\($0)" } ?? "")) + if let error { subsError = error; break } + subs += page + moreSubs = json["has_more"] as? Bool ?? false + guard moreSubs, let last = subs.last?["id"] as? String else { break } + cursor = last + } + // Estimated monthly revenue, one total per currency. Metered and tiered + // prices have no fixed amount per period and discounts aren't applied. + var monthlyByCurrency: [String: Double] = [:] + for sub in subs { + let subCurrency = (sub["currency"] as? String)?.lowercased() + let items = (sub["items"] as? [String: Any])?["data"] as? [[String: Any]] ?? [] + for item in items { + let price = item["price"] as? [String: Any] ?? [:] + let recurring = price["recurring"] as? [String: Any] ?? [:] + if recurring["usage_type"] as? String == "metered" || price["billing_scheme"] as? String == "tiered" { continue } + let perUnit = (price["unit_amount"] as? Int).map { Double($0) } + ?? Double(price["unit_amount_decimal"] as? String ?? "") ?? 0 + let unit = perUnit * Double(item["quantity"] as? Int ?? 1) + let count = Double(recurring["interval_count"] as? Int ?? 1) + let perMonth: Double + switch recurring["interval"] as? String { + case "year": perMonth = unit / (12 * count) + case "week": perMonth = unit * 4.345 / count + case "day": perMonth = unit * 30.4 / count + default: perMonth = unit / count + } + let itemCurrency = subCurrency ?? (price["currency"] as? String)?.lowercased() ?? currency + monthlyByCurrency[itemCurrency, default: 0] += perMonth + } + } + let largest = monthlyByCurrency.max { $0.value < $1.value } + let mrrCurrency = monthlyByCurrency[currency] != nil ? currency : (largest?.key ?? currency) + let monthly = monthlyByCurrency[mrrCurrency] ?? 0 + + var sections = [DetailSection(title: "Payments", items: chargeItems, footer: chargesError)] + if !payoutItems.isEmpty || payoutsError != nil { + sections.append(DetailSection(title: "Payouts", items: payoutItems, footer: payoutsError)) + } + if let subsError { sections.append(DetailSection(title: "Subscriptions", items: [], footer: subsError)) } + return ServiceDetail(pillId: "integration_stripe", + stats: [DetailStat(label: "Available", value: money(available, currency), tone: .ok), + DetailStat(label: "Pending", value: money(pending, currency)), + DetailStat(label: "Subscriptions", + value: subsError != nil ? "—" : (moreSubs ? "\(subs.count)+" : "\(subs.count)"), + tone: .info), + DetailStat(label: "Est. MRR", + value: subsError != nil ? "—" : money(Int(monthly.rounded()), mrrCurrency), + tone: .info)], + sections: sections, fetchedAt: Date()) + } + + // Resend: the latest emails and how they went, and the domains. Read only. + private static func resend() async throws -> ServiceDetail { + let key = try secret("resend-api-key", "Resend") + let headers = ["Authorization": "Bearer \(key)"] + let emailsJSON = try await request("https://api.resend.com/emails?limit=20", headers: headers) + let emails = (emailsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + var bounced = 0, delivered = 0 + let emailItems = emails.map { e -> DetailItem in + let event = e["last_event"] as? String ?? "sent" + let tone: ServiceTone + switch event { + case "delivered", "opened", "clicked": tone = .ok; delivered += 1 + case "bounced", "complained", "failed", "suppressed": tone = .error; bounced += 1 + case "delivery_delayed": tone = .warning + default: tone = .info + } + let to = (e["to"] as? [String])?.joined(separator: ", ") ?? "" + return DetailItem(title: e["subject"] as? String ?? "(no subject)", subtitle: to, tone: tone, + date: date(e["created_at"]), badge: event.replacingOccurrences(of: "_", with: " ").capitalized) + } + let domainsJSON = try? await request("https://api.resend.com/domains", headers: headers) + let domains = (domainsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + let domainItems = domains.map { d in + let status = d["status"] as? String ?? "" + return DetailItem(title: d["name"] as? String ?? "Domain", subtitle: d["region"] as? String ?? "", + tone: status == "verified" ? .ok : (status == "failed" ? .error : .warning), + badge: status.replacingOccurrences(of: "_", with: " ").capitalized) + } + var sections = [DetailSection(title: "Latest emails", items: emailItems)] + if !domainItems.isEmpty { sections.append(DetailSection(title: "Domains", items: domainItems)) } + return ServiceDetail(pillId: "integration_resend", + stats: [DetailStat(label: "Delivered", value: "\(delivered)", tone: .ok), + DetailStat(label: "Bounced", value: "\(bounced)", tone: bounced > 0 ? .error : .idle), + DetailStat(label: "Domains", value: "\(domains.count)")], + sections: sections, fetchedAt: Date()) + } + + // Cal.com: upcoming bookings. Read only: cancelling emails the guest and can refund or charge them. + private static func calcom() async throws -> ServiceDetail { + let key = try secret("calcom-api-key", "Cal.com") + let headers = ["Authorization": "Bearer \(key)", "cal-api-version": "2024-08-13"] + let iso = ISO8601DateFormatter() + let now = Date() + var components = URLComponents(string: "https://api.cal.com/v2/bookings")! + components.queryItems = [URLQueryItem(name: "status", value: "upcoming"), + URLQueryItem(name: "afterStart", value: iso.string(from: now)), + URLQueryItem(name: "take", value: "30")] + let json = try await request(components.url!.absoluteString, headers: headers) + let bookings = (json as? [String: Any])?["data"] as? [[String: Any]] ?? [] + let calendar = Calendar.current + var today = 0, week = 0 + let items = bookings.compactMap { b -> DetailItem? in + guard let start = date(b["start"] ?? b["startTime"]) else { return nil } + if calendar.isDateInToday(start) { today += 1 } + if start.timeIntervalSince(now) < 7 * 24 * 3600 { week += 1 } + let attendee = (b["attendees"] as? [[String: Any]])?.first + let who = attendee?["name"] as? String ?? attendee?["email"] as? String ?? "" + let when = start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + return DetailItem(title: b["title"] as? String ?? "Meeting", + subtitle: who.isEmpty ? when : "\(when) · \(who)", + tone: calendar.isDateInToday(start) ? .warning : .info, + date: start, url: (b["meetingUrl"] as? String) ?? (b["location"] as? String).flatMap { $0.hasPrefix("https://") ? $0 : nil }, + badge: (b["status"] as? String)?.capitalized) + } + return ServiceDetail(pillId: "integration_calcom", + stats: [DetailStat(label: "Today", value: "\(today)", tone: today > 0 ? .warning : .idle), + DetailStat(label: "Next 7 days", value: "\(week)", tone: .info), + DetailStat(label: "Upcoming", value: "\(items.count)")], + sections: [DetailSection(title: "Upcoming bookings", items: items, + footer: items.isEmpty ? "Nothing booked." : nil)], + fetchedAt: Date()) + } + + // n8n: workflows (activate, deactivate) and executions (retry a failed one). + private static func n8n() async throws -> ServiceDetail { + let key = try secret("n8n-api-key", "n8n") + let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] + // The list comes in pages (at most 250, then `cursor` = the previous `nextCursor`): read + // them all so the counts are right. + var workflows: [[String: Any]] = [] + var cursor: String? + repeat { + var url = "\(base)/api/v1/workflows?limit=250" + if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { + url += "&cursor=\(encoded)" + } + let page = try await request(url, headers: headers) as? [String: Any] + workflows += page?["data"] as? [[String: Any]] ?? [] + cursor = page?["nextCursor"] as? String + } while !(cursor ?? "").isEmpty && workflows.count < 5_000 + var names: [String: String] = [:] + var active = 0 + let workflowItems = workflows.compactMap { w -> DetailItem? in + guard let id = (w["id"] as? String) ?? (w["id"] as? Int).map(String.init) else { return nil } + let name = w["name"] as? String ?? "Workflow" + names[id] = name + // Archived workflows are listed but can't be turned on (n8n answers 400). + if w["isArchived"] as? Bool ?? false { return nil } + let isActive = w["active"] as? Bool ?? false + if isActive { active += 1 } + return DetailItem(title: name, tone: isActive ? .ok : .idle, date: date(w["updatedAt"]), + url: "\(base)/workflow/\(id)", badge: isActive ? "Active" : "Off", + actions: [isActive + ? ServiceActionDef(kind: "n8n.deactivate", title: "Turn off", symbol: "pause.circle", + target: id, destructive: true, confirm: "Stop \(name) from running?") + : ServiceActionDef(kind: "n8n.activate", title: "Turn on", symbol: "play.circle", target: id)]) + } + let executionsJSON = try? await request("\(base)/api/v1/executions?limit=15", headers: headers) + let executions = (executionsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + var failed = 0 + let executionItems = executions.compactMap { e -> DetailItem? in + guard let id = (e["id"] as? String) ?? (e["id"] as? Int).map(String.init) else { return nil } + let workflowId = (e["workflowId"] as? String) ?? (e["workflowId"] as? Int).map(String.init) ?? "" + let status = e["status"] as? String ?? ((e["finished"] as? Bool ?? false) ? "success" : "error") + let isFailure = status == "error" || status == "crashed" || status == "failed" + if isFailure { failed += 1 } + return DetailItem(title: names[workflowId] ?? "Workflow \(workflowId)", subtitle: "Execution \(id)", + tone: isFailure ? .error : (status == "success" ? .ok : .warning), + date: date(e["startedAt"]), url: "\(base)/workflow/\(workflowId)/executions/\(id)", + badge: status.capitalized, + actions: isFailure ? [ServiceActionDef(kind: "n8n.retry", title: "Retry", symbol: "arrow.clockwise", target: id)] : []) + } + return ServiceDetail(pillId: "integration_n8n", + stats: [DetailStat(label: "Workflows", value: "\(workflowItems.count)"), + DetailStat(label: "Active", value: "\(active)", tone: .ok), + DetailStat(label: "Failed runs", value: "\(failed)", tone: failed > 0 ? .error : .idle)], + sections: [DetailSection(title: "Latest runs", items: executionItems), + DetailSection(title: "Workflows", + items: Array(workflowItems.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }.prefix(50)))], + fetchedAt: Date()) + } + + // Notion: the pages and databases edited last. Read only. + private static func notion() async throws -> ServiceDetail { + let key = try secret("notion-api-key", "Notion") + let headers = ["Authorization": "Bearer \(key)", "Notion-Version": "2022-06-28"] + let json = try await request("https://api.notion.com/v1/search", method: "POST", headers: headers, + body: ["sort": ["direction": "descending", "timestamp": "last_edited_time"], "page_size": 25]) + let results = (json as? [String: Any])?["results"] as? [[String: Any]] ?? [] + var pages: [DetailItem] = [] + var databases: [DetailItem] = [] + for object in results { + let kind = object["object"] as? String ?? "page" + var title = "" + if kind == "database" { + title = ((object["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" + } else if let properties = object["properties"] as? [String: Any] { + for case let property as [String: Any] in properties.values where property["type"] as? String == "title" { + title = ((property["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" + } + } + var emoji = "" + if let icon = object["icon"] as? [String: Any], icon["type"] as? String == "emoji" { emoji = (icon["emoji"] as? String ?? "") + " " } + let item = DetailItem(title: emoji + (title.isEmpty ? "Untitled" : title), + date: date(object["last_edited_time"]), url: object["url"] as? String, + badge: (object["archived"] as? Bool ?? false) ? "Archived" : nil) + if kind == "database" { databases.append(item) } else { pages.append(item) } + } + var sections = [DetailSection(title: "Edited lately", items: pages)] + if !databases.isEmpty { sections.append(DetailSection(title: "Databases", items: databases)) } + let editedToday = results.filter { date($0["last_edited_time"]).map(Calendar.current.isDateInToday) ?? false }.count + return ServiceDetail(pillId: "integration_notion", + stats: [DetailStat(label: "Edited today", value: "\(editedToday)", tone: editedToday > 0 ? .info : .idle), + DetailStat(label: "Pages", value: "\(pages.count)"), + DetailStat(label: "Databases", value: "\(databases.count)")], + sections: sections, fetchedAt: Date()) + } + + // MARK: Acting + + /// Runs one action. Returns a short line for the iPhone. + static func perform(kind: String, target: String) async throws -> String { + switch kind { + case "vercel.redeploy": + let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown deployment") } + var body: [String: Any] = ["name": parts[1], "deploymentId": parts[0]] + if !parts[2].isEmpty { body["target"] = parts[2] } + let json = try await request(VercelAPI.url("https://api.vercel.com/v13/deployments"), method: "POST", + headers: try vercelHeaders(), body: body) + let url = (json as? [String: Any])?["url"] as? String + return url.map { "Building \($0)" } ?? "New deployment started" + case "vercel.promote": + // Vercel only promotes production builds in place: a preview is rebuilt for + // production, as `vercel promote` does. + let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 2 else { throw Failure(status: 0, message: "Unknown deployment") } + let json = try await request(VercelAPI.url("https://api.vercel.com/v13/deployments"), method: "POST", + headers: try vercelHeaders(), + body: ["deploymentId": parts[0], "name": parts[1], "target": "production", + "meta": ["action": "promote"]]) + let url = (json as? [String: Any])?["url"] as? String + return url.map { "Building \($0) for production" } ?? "Production build started" + case "vercel.cancel": + _ = try await request(VercelAPI.url("https://api.vercel.com/v12/deployments/\(target)/cancel"), method: "PATCH", + headers: try vercelHeaders()) + return "Build canceled" + case "github.rerun": + // "owner/repo/123" + let parts = target.split(separator: "/").map(String.init) + guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown run") } + _ = try await request("https://api.github.com/repos/\(parts[0])/\(parts[1])/actions/runs/\(parts[2])/rerun-failed-jobs", + method: "POST", headers: try githubHeaders()) + return "Failed jobs started again" + case "github.approve", "github.merge": + // "owner/repo#12" + let pieces = target.split(separator: "#").map(String.init) + guard pieces.count == 2 else { throw Failure(status: 0, message: "Unknown pull request") } + if kind == "github.approve" { + _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/reviews", method: "POST", + headers: try githubHeaders(), body: ["event": "APPROVE"]) + return "Approved" + } + _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/merge", method: "PUT", + headers: try githubHeaders(), body: ["merge_method": "squash"]) + return "Merged" + case "n8n.activate", "n8n.deactivate", "n8n.retry": + let key = try secret("n8n-api-key", "n8n") + let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] + switch kind { + case "n8n.activate": + _ = try await request("\(base)/api/v1/workflows/\(target)/activate", method: "POST", headers: headers) + return "Turned on" + case "n8n.deactivate": + _ = try await request("\(base)/api/v1/workflows/\(target)/deactivate", method: "POST", headers: headers) + return "Turned off" + default: + do { + _ = try await request("\(base)/api/v1/executions/\(target)/retry", method: "POST", headers: headers) + } catch let failure as Failure where failure.status == 404 || failure.status == 405 { + // POST /api/v1/executions/{id}/retry only exists from n8n 1.112.0. + throw Failure(status: 0, message: "Couldn't retry this run. It may have been deleted, or your n8n is older than 1.112, which is needed to retry from the iPhone.") + } + return "Running again" + } + default: + throw Failure(status: 0, message: "This action isn't available") + } + } + + /// The newest failed (or timed out) workflow run on a branch, as the "owner/repo/123" target of + /// "github.rerun". nil when the latest runs all passed. + static func latestFailedRun(repo: String, branch: String) async throws -> String? { + var components = URLComponents(string: "https://api.github.com/repos/\(repo)/actions/runs") + components?.queryItems = [URLQueryItem(name: "branch", value: branch), URLQueryItem(name: "per_page", value: "10")] + guard let url = components?.url else { throw Failure(status: 0, message: "Bad address") } + let json = try await request(url.absoluteString, headers: try githubHeaders()) + let runs = (json as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? [] + let failed = runs.first { + $0["status"] as? String == "completed" + && ["failure", "timed_out"].contains($0["conclusion"] as? String ?? "") + } + return (failed?["id"] as? Int).map { "\(repo)/\($0)" } + } + + /// Why a Vercel deployment failed: its errorMessage, else the last error line of its build log. + static func vercelFailureReason(id: String) async throws -> String? { + let headers = try vercelHeaders() + if let d = try? await request(VercelAPI.url("https://api.vercel.com/v13/deployments/\(id)"), headers: headers) + as? [String: Any], + let message = d["errorMessage"] as? String, !message.isEmpty { + return message + } + // Build log, newest first + let json = try await request( + VercelAPI.url("https://api.vercel.com/v3/deployments/\(id)/events?direction=backward&limit=100"), + headers: headers) + let events = json as? [[String: Any]] ?? [] + let ansi = try? NSRegularExpression(pattern: "\u{1B}\\[[0-9;]*[A-Za-z]") + func line(_ event: [String: Any]) -> String? { + let raw = (event["text"] as? String) ?? ((event["payload"] as? [String: Any])?["text"] as? String) ?? "" + let range = NSRange(raw.startIndex..., in: raw) + let clean = ansi?.stringByReplacingMatches(in: raw, range: range, withTemplate: "") ?? raw + let trimmed = clean.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let lines = events.compactMap { e in line(e).map { (type: e["type"] as? String ?? "", text: $0) } } + let generic = ["Error: Command", "exited with", "Build failed"] + let specific = lines.first { l in + l.text.range(of: "error", options: .caseInsensitive) != nil + && !generic.contains(where: { l.text.contains($0) }) + } + return (specific ?? lines.first { $0.type == "stderr" } ?? lines.first)?.text + } + + private static func vercelHeaders() throws -> [String: String] { + let token = try secret("vercel-token", "Vercel") + return ["Authorization": "Bearer \(token)", "Accept": "application/json"] + } + + private static func githubHeaders() throws -> [String: String] { + let token = try secret("github-token", "GitHub") + return ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"] + } +} diff --git a/NotchBuddy/Sources/App/SessionGitHubLinker.swift b/NotchBuddy/Sources/App/SessionGitHubLinker.swift new file mode 100644 index 0000000000..989d5f0635 --- /dev/null +++ b/NotchBuddy/Sources/App/SessionGitHubLinker.swift @@ -0,0 +1,57 @@ +import Foundation + +// MARK: - SessionGitHubLinker +// +// Finds the open PR and the CI of the branch the focused Claude Code session works on. +// Runs only while the island is open on that session (see OverviewView): reads the +// session's .git (branch may change between calls), then asks GitHub at most once a +// minute per branch, every 30 s while CI is running. + +@MainActor +final class SessionGitHubLinker { + static let shared = SessionGitHubLinker() + private var inFlight: Set = [] + private init() {} + + func refresh(for task: AgentTask?) { + guard let task, PillCatalog.definition(for: task.id)?.category == .workspace, + let cwd = task.sessionCwd, !cwd.isEmpty else { return } + let state = AppState.shared + guard let git = GitRepoInfo.read(cwd: cwd) else { + if state.sessionGit[task.id] != nil { state.sessionGit[task.id] = nil } + return + } + if state.sessionGit[task.id] != git { state.sessionGit[task.id] = git } + + guard let token = KeychainStore.shared.get("github-token"), + let variables = SessionBranchInfo.variables(repo: git.repo, branch: git.branch) else { return } + let key = "\(git.repo)@\(git.branch)" + if let known = state.sessionBranches[key], + !GitHubPulse.isStale(fetchedAt: known.fetchedAt, maxAge: known.hasPending ? 30 : 60) { return } + guard inFlight.insert(key).inserted else { return } + + Task { + defer { inFlight.remove(key) } + guard let url = URL(string: "https://api.github.com/graphql"), + let body = try? JSONSerialization.data(withJSONObject: ["query": SessionBranchInfo.query, + "variables": variables]) else { return } + var req = URLRequest(url: url, timeoutInterval: 15) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = body + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let info = SessionBranchInfo.parse(data, repo: git.repo, branch: git.branch) else { + appendAppLog("github.log", "session branch lookup failed for \(key)") + return + } + state.sessionBranches[key] = info + } + } + + /// Next lookup ignores the cache (after a merge or a re-run). + func invalidate(repo: String, branch: String) { + AppState.shared.sessionBranches["\(repo)@\(branch)"]?.fetchedAt = .distantPast + } +} diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 5fea56ff22..3d69f443bf 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -98,9 +98,15 @@ struct SettingsView: View { @State private var hotkeyFlags: UInt = AppState.shared.hotkeyFlags @State private var hotkeyCode: UInt16 = AppState.shared.hotkeyCode - // Vercel project filter + // Vercel project filter + team scope @State private var vercelProjects: [String] = [] @State private var loadingVercel: Bool = false + @State private var vercelTeams: [(id: String, name: String)] = [] + @State private var loadingVercelTeams: Bool = false + + // GitHub watched repos + @State private var githubRepos: [String] = [] + @State private var loadingGithubRepos: Bool = false // n8n workflow filter @State private var n8nWorkflows: [String] = [] @@ -1021,6 +1027,28 @@ struct SettingsView: View { } SecureField("Token", text: $vercelToken) .textFieldStyle(.roundedBorder) + HStack(spacing: 6) { + Text("Scope") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Picker("", selection: vercelTeamBinding) { + Text("Personal account").tag(String?.none) + ForEach(vercelTeamChoices, id: \.id) { team in + Text(team.name).tag(Optional(team.id)) + } + } + .labelsHidden() + .controlSize(.small) + .fixedSize() + Spacer() + if loadingVercelTeams { + ProgressView().scaleEffect(0.6) + } else { + Button(vercelTeams.isEmpty ? "Load teams" : "Refresh") { loadVercelTeams() } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } IntegrationFilterRow( label: String(localized: "integrations.projects"), items: vercelProjects, @@ -1041,6 +1069,16 @@ struct SettingsView: View { Text(String(localized: "integrations.github.token-hint")) .font(.system(size: 10)) .foregroundColor(Color(hex: "#8E939C")) + IntegrationFilterRow( + label: "Repositories", + items: githubRepoChoices, + filter: $state.githubWatchedRepos, + loading: loadingGithubRepos, + onLoad: loadGithubRepos, + emptyMeansAll: false, + maxSelected: 20, + emptyCaption: "None selected: your 10 most recently pushed repos are watched." + ) } // Stripe @@ -1493,6 +1531,97 @@ struct SettingsView: View { } } + // MARK: - Vercel team scope + + /// Loaded teams, plus the saved team when the list hasn't been loaded yet. + private var vercelTeamChoices: [(id: String, name: String)] { + if let id = state.vercelTeamId, !vercelTeams.contains(where: { $0.id == id }) { + return [(id, state.vercelTeamName ?? id)] + vercelTeams + } + return vercelTeams + } + + private var vercelTeamBinding: Binding { + Binding( + get: { state.vercelTeamId }, + set: { id in + guard id != state.vercelTeamId else { return } + state.vercelTeamId = id + state.vercelTeamName = vercelTeamChoices.first { $0.id == id }?.name + // Projects belong to a scope: start over in the new one + state.vercelProjectFilter = [] + state.vercelFocusProject = nil + vercelProjects = [] + VercelPoller.shared.scopeChanged() + } + ) + } + + private func loadVercelTeams() { + guard let token = KeychainStore.shared.get("vercel-token") else { + statusMessage = "❌ Save Vercel token first." + return + } + loadingVercelTeams = true + guard let url = URL(string: "https://api.vercel.com/v2/teams?limit=100") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { data, _, _ in + var teams: [(id: String, name: String)] = [] + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let list = json["teams"] as? [[String: Any]] { + teams = list.compactMap { t in + guard let id = t["id"] as? String else { return nil } + return (id, (t["name"] as? String) ?? (t["slug"] as? String) ?? id) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + DispatchQueue.main.async { + self.vercelTeams = teams + self.loadingVercelTeams = false + if teams.isEmpty { self.statusMessage = "No Vercel teams found: using your personal account." } + } + }.resume() + } + + // MARK: - GitHub repo list + + /// Loaded repos, plus watched ones missing from the list so they can still be unchecked. + private var githubRepoChoices: [String] { + guard !githubRepos.isEmpty else { return [] } + return Array(Set(githubRepos).union(state.githubWatchedRepos)).sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + } + } + + private func loadGithubRepos() { + guard let token = KeychainStore.shared.get("github-token") else { + statusMessage = "❌ Save GitHub token first." + return + } + loadingGithubRepos = true + Task { + // Up to 5 pages of 100, most recently pushed first + var names: [String] = [] + for page in 1...5 { + guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&page=\(page)&sort=pushed&affiliation=owner,collaborator,organization_member") else { break } + var req = URLRequest(url: url, timeoutInterval: 15) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + guard let (data, _) = try? await URLSession.shared.data(for: req), + let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { break } + names += repos + .filter { ($0["archived"] as? Bool) != true } + .compactMap { $0["full_name"] as? String } + if repos.count < 100 { break } + } + githubRepos = names + loadingGithubRepos = false + if names.isEmpty { statusMessage = "❌ No GitHub repositories found." } + } + } + // MARK: - Vercel project list private func loadVercelProjects() { @@ -1501,7 +1630,7 @@ struct SettingsView: View { return } loadingVercel = true - guard let url = URL(string: "https://api.vercel.com/v9/projects?limit=100") else { return } + guard let url = URL(string: VercelAPI.url("https://api.vercel.com/v9/projects?limit=100")) else { return } var req = URLRequest(url: url, timeoutInterval: 10) req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") URLSession.shared.dataTask(with: req) { data, response, _ in @@ -1661,6 +1790,33 @@ struct IntegrationFilterRow: View { @Binding var filter: Set let loading: Bool let onLoad: () -> Void + /// true: an empty filter means every item is watched (Vercel, n8n). + /// false: an empty filter means none is picked and the caller falls back to a default (GitHub). + var emptyMeansAll: Bool = true + var maxSelected: Int? = nil + var emptyCaption: String? = nil + + private func isOn(_ item: String) -> Bool { + emptyMeansAll ? (filter.isEmpty || filter.contains(item)) : filter.contains(item) + } + + private func set(_ item: String, on: Bool) { + guard emptyMeansAll else { + if on { + if let max = maxSelected, filter.count >= max { return } + filter.insert(item) + } else { + filter.remove(item) + } + return + } + if on { filter.insert(item) } + else { + if filter.isEmpty { filter = Set(items).subtracting([item]) } + else { filter.remove(item) } + if filter.count == items.count { filter = [] } + } + } var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -1687,15 +1843,8 @@ struct IntegrationFilterRow: View { VStack(alignment: .leading, spacing: 2) { ForEach(items, id: \.self) { item in Toggle(item, isOn: Binding( - get: { filter.isEmpty || filter.contains(item) }, - set: { on in - if on { filter.insert(item) } - else { - if filter.isEmpty { filter = Set(items).subtracting([item]) } - else { filter.remove(item) } - if filter.count == items.count { filter = [] } - } - } + get: { isOn(item) }, + set: { on in set(item, on: on) } )) .font(.system(size: 11)) .toggleStyle(.checkbox) @@ -1703,11 +1852,22 @@ struct IntegrationFilterRow: View { } .padding(.leading, 4) if !filter.isEmpty { - Text(String(format: String(localized: "Watching %lld of %lld"), Int64(filter.count), Int64(items.count))) + let limit = maxSelected.map { filter.count >= $0 ? " (limit \($0))" : "" } ?? "" + Text(String(format: String(localized: "Watching %lld of %lld"), Int64(filter.count), Int64(items.count)) + limit) .font(.system(size: 10)) .foregroundColor(.secondary) } } + if filter.isEmpty, let caption = emptyCaption { + Text(caption) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } else if !filter.isEmpty, items.isEmpty, !emptyMeansAll { + Text(filter.sorted().joined(separator: ", ")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(2) + } } } } diff --git a/NotchBuddy/Sources/App/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift new file mode 100644 index 0000000000..75c62f3c47 --- /dev/null +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -0,0 +1,857 @@ +import SwiftUI + +// MARK: - Side panel (right card) +// +// Opened from the GitHub and Vercel cards: it takes the place of the agent pills +// until it is closed (✕, Esc, tapping its opener again) or anything else needs the +// island — an approval, a question, a focus change, the island collapsing. + +struct SidePanelView: View { + @ObservedObject var state: AppState + let panel: SidePanel + + private func close() { + withAnimation(.easeInOut(duration: 0.16)) { state.sidePanel = nil } + } + + /// Another pill has an alert badge while its pill is hidden behind the panel. + private var othersNeedAttention: Bool { + state.tasks.contains { $0.id != state.focusId && $0.pillBadge != nil } + } + + var body: some View { + Group { + switch panel { + case .githubRepos: + FocusPickerPanel( + title: "Repositories", accent: "#F4505E", allLabel: "All repos", + options: state.githubFocusOptions, + selection: state.effectiveGithubFocus, + shortLabel: { $0.split(separator: "/").last.map(String.init) ?? $0 }, + attention: othersNeedAttention, onClose: close, + onPick: { repo in + state.githubFocusRepo = repo + GithubPoller.shared.refreshIfStale() + close() + }) + case .vercelProjects: + FocusPickerPanel( + title: "Projects", accent: "#7C5CFF", allLabel: "All projects", + options: state.vercelFocusOptions, + selection: state.effectiveVercelFocus, + attention: othersNeedAttention, onClose: close, + onPick: { project in + state.vercelFocusProject = project + close() + }) + case .github(let section): + GitHubSectionPanel(section: section, pulse: state.githubVisiblePulse, + attention: othersNeedAttention, onClose: close) + case .sessionBranch(let taskId): + SessionBranchPanel(state: state, taskId: taskId, attention: othersNeedAttention, onClose: close) + case .vercelDeployment(let id): + if let dep = state.vercelDeployments.first(where: { $0.id == id }) { + VercelDeploymentPanel(deployment: dep, attention: othersNeedAttention, onClose: close) + } else { + SidePanelChrome(title: "Deployment", accent: "#7C5CFF", attention: othersNeedAttention, + onClose: close) { + SidePanelEmpty(text: "This deployment is no longer in the list") + } + } + } + } + .onExitCommand { close() } + } +} + +// MARK: - Chrome (header + content) + +struct SidePanelChrome: View { + let title: String + let accent: String + var subtitle: String? = nil + /// Result of the last action, shown in place of the subtitle for a few seconds. + var status: PanelStatus? = nil + var trailing: AnyView? = nil + let attention: Bool + let onClose: () -> Void + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: accent)) + .frame(width: 6, height: 6) + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + if let status { + Text(status.text) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(Color(hex: status.neutral ? "#9398A1" : status.ok ? "#22C55E" : "#F4505E")) + .lineLimit(1) + .truncationMode(.tail) + .help(status.text) + .transition(.opacity) + } else if let subtitle { + Text(subtitle) + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1) + } + Spacer(minLength: 4) + if let trailing { trailing } + SidePanelCloseButton(attention: attention, action: onClose) + } + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .padding(.top, 9) + .padding(.horizontal, 12) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +/// Same look as the ↗ button of the left card. The amber dot says another pill, +/// hidden behind the panel, has something to show. +private struct SidePanelCloseButton: View { + let attention: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(Color(hex: isHovered ? "#C5C8CD" : "#5F646D")) + .frame(width: 16, height: 16) + .background(Color.white.opacity(isHovered ? 0.12 : 0.07)) + .clipShape(Circle()) + .overlay(alignment: .topTrailing) { + if attention { + Circle() + .fill(Color(hex: "#F5A524")) + .frame(width: 5, height: 5) + .offset(x: 1, y: -1) + } + } + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(attention ? "Close — other agents have updates" : "Close") + } +} + +private struct SidePanelEmpty: View { + let text: String + var body: some View { + Text(text) + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.top, 2) + } +} + +/// Fades the last rows of a list that scrolls. +private struct ScrollFade: ViewModifier { + let active: Bool + func body(content: Content) -> some View { + content.mask( + Group { + if active { + LinearGradient(stops: [.init(color: .black, location: 0), + .init(color: .black, location: 0.78), + .init(color: .clear, location: 1)], + startPoint: .top, endPoint: .bottom) + } else { + Color.black + } + } + ) + } +} + +// MARK: - Focus picker (repo / project chips) + +struct FocusPickerPanel: View { + let title: String + let accent: String + let allLabel: String + let options: [String] + let selection: String? + var shortLabel: (String) -> String = { $0 } + let attention: Bool + let onClose: () -> Void + let onPick: (String?) -> Void + + private let columns = [GridItem(.flexible(), spacing: 4), GridItem(.flexible(), spacing: 4)] + + var body: some View { + SidePanelChrome(title: title, accent: accent, subtitle: "\(options.count)", + attention: attention, onClose: onClose) { + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 4) { + FocusChip(label: allLabel, accent: accent, selected: selection == nil) { onPick(nil) } + ForEach(options, id: \.self) { option in + FocusChip(label: shortLabel(option), accent: accent, selected: option == selection) { + onPick(option) + } + .help(option) + } + } + .padding(.vertical, 1) + } + .modifier(ScrollFade(active: options.count + 1 > 4)) + } + } +} + +/// A pill-shaped choice, styled like the agent pills. +private struct FocusChip: View { + let label: String + let accent: String + let selected: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: { + SoundEngine.shared.play("blip") + action() + }) { + HStack(spacing: 4) { + if selected { + Image(systemName: "checkmark") + .font(.system(size: 7, weight: .bold)) + } + Text(label) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundColor(selected || isHovered + ? Color(hex: accent).lighter(by: 0.3) + : Color(hex: "#6B7079")) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .frame(height: 24) + .background( + Capsule().fill(selected ? Color(hex: accent).opacity(0.18) + : isHovered ? Color(hex: accent).opacity(0.10) + : Color(hex: "#0E0F11")) + ) + .overlay( + Capsule().stroke(Color(hex: accent).opacity(selected ? 0.55 : isHovered ? 0.4 : 0.14), + lineWidth: 1) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { h in + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } + } + } +} + +// MARK: - GitHub section (PRs, reviews, default-branch CI) + +struct GitHubSectionPanel: View { + let section: GitHubDetailSection + let pulse: GitHubPulse? + let attention: Bool + let onClose: () -> Void + @ObservedObject private var appState = AppState.shared + @StateObject private var status = PanelStatusModel() + + private var title: String { + switch section { + case .myPRs: return "My PRs" + case .toReview: return "To review" + case .mainCI: return "Default branch CI" + case .activity: return "Activity" + } + } + + private var prs: [GitHubPR] { + guard let pulse else { return [] } + switch section { + case .myPRs: return pulse.myPRs + case .toReview: return pulse.toReview + case .mainCI, .activity: return [] + } + } + + private var repos: [GitHubRepoCI] { + section == .mainCI ? (pulse?.mainCI ?? []) : [] + } + + private var total: Int { prs.count + repos.count } + + @ViewBuilder + private func prAction(_ pr: GitHubPR) -> some View { + let ref = "\(pr.repo)#\(pr.number)" + if section == .myPRs, let preview = appState.vercelPreview(repo: pr.repo, branch: pr.headRef) { + VercelPreviewDot(deployment: preview) + } + if section == .myPRs && !pr.isDraft { + PanelActionButton(title: "Merge", symbol: "arrow.triangle.merge", accent: "#A371F7", + help: "Squash and merge \(ref)", + perform: { try await ServiceAPI.perform(kind: "github.merge", target: ref) }, + onResult: finished) + } else if section == .toReview { + PanelActionButton(title: "Approve", symbol: "checkmark.seal", accent: "#22C55E", + help: "Approve \(ref)", + perform: { try await ServiceAPI.perform(kind: "github.approve", target: ref) }, + onResult: finished) + } + } + + private func rerunAction(_ repo: GitHubRepoCI) -> some View { + PanelActionButton(title: "Re-run", symbol: "arrow.clockwise", accent: "#F5A524", + help: "Re-run the failed jobs of the latest failed run on \(repo.branch)", + perform: { + guard let run = try await ServiceAPI.latestFailedRun(repo: repo.repo, branch: repo.branch) else { + throw PanelNotice(message: "No failed run to re-run") + } + return try await ServiceAPI.perform(kind: "github.rerun", target: run) + }, + onResult: finished, + onNotice: { status.show($0, ok: true, neutral: true) }) + } + + private func finished(_ message: String, _ ok: Bool) { + status.show(message, ok: ok) + // Give GitHub a moment, then show where things are. + if ok { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { GithubPoller.shared.triggerPulseNow() } } + } + + var body: some View { + SidePanelChrome(title: title, accent: "#F4505E", subtitle: "\(total)", status: status.current, + attention: attention, onClose: onClose) { + if total == 0 { + SidePanelEmpty(text: "Nothing here") + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(prs.enumerated()), id: \.element.id) { idx, pr in + HStack(spacing: 6) { + GitHubPRRowView(pr: pr, showCI: section == .myPRs, + selected: appState.cardSelection == idx) + prAction(pr) + } + } + ForEach(Array(repos.enumerated()), id: \.element.repo) { idx, repo in + HStack(spacing: 6) { + GitHubRepoCIRowView(repo: repo, + selected: appState.cardSelection == prs.count + idx) + if repo.ci == .failure { rerunAction(repo) } + } + } + } + } + .modifier(ScrollFade(active: total > 3)) + } + } + .onAppear { + GithubPoller.shared.refreshIfStale() + appState.cardItemCount = total + } + .onChange(of: total) { _, n in appState.cardItemCount = n } + .onDisappear { appState.cardItemCount = 0 } + .onReceive(NotificationCenter.default.publisher(for: .islandActivateCardSelection)) { _ in + guard let sel = appState.cardSelection else { return } + let link: String + if sel < prs.count { + link = prs[sel].url + } else { + let i = sel - prs.count + guard i < repos.count else { return } + link = repos[i].url.hasSuffix("/") ? repos[i].url + "actions" : repos[i].url + "/actions" + } + if let url = safeWebURL(link), url.host == "github.com" { NSWorkspace.shared.open(url) } + } + } +} + +// MARK: - Vercel deployment + +struct VercelDeploymentPanel: View { + let deployment: VercelDeployment + let attention: Bool + let onClose: () -> Void + @StateObject private var status = PanelStatusModel() + @State private var failureReason: String? = nil + + private func finished(_ message: String, _ ok: Bool) { + status.show(message, ok: ok) + if ok { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { VercelPoller.shared.pollNow() } } + } + + private var commitLine: String { + deployment.commitMessage?.split(separator: "\n").first.map(String.init) ?? "No commit message" + } + + private var accentHex: String { deployment.stateColor } + + private var targetLabel: String { + guard let t = deployment.target, !t.isEmpty else { return "Preview" } + return t.capitalized + } + + var body: some View { + let accent = Color(hex: accentHex) + SidePanelChrome( + title: deployment.projectName, accent: accentHex, status: status.current, + trailing: AnyView( + Text(deployment.statusLabel) + .font(.system(size: 9.5, weight: .medium)) + .foregroundColor(accent) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(accent.opacity(0.14)) + .clipShape(Capsule()) + ), + attention: attention, onClose: onClose + ) { + VStack(alignment: .leading, spacing: 5) { + if deployment.state == "ERROR" { + // Failed: the reason takes the commit line (the commit stays in the tooltip) + Text(failureReason ?? "Looking for the error…") + .font(.system(size: 10.5, design: failureReason == nil ? .default : .monospaced)) + .foregroundColor(Color(hex: failureReason == nil ? "#6B7079" : "#F4505E").lighter(by: 0.2)) + .lineLimit(1) + .truncationMode(.tail) + .help([failureReason, commitLine].compactMap { $0 }.joined(separator: "\n\n")) + } else { + Text(commitLine) + .font(.system(size: 11)) + .foregroundColor(Color(hex: deployment.commitMessage == nil ? "#6B7079" : "#C5C8CD")) + .lineLimit(1) + .truncationMode(.tail) + } + HStack(spacing: 8) { + if let branch = deployment.branch { + Label(branch, systemImage: "arrow.branch") + .lineLimit(1) + .truncationMode(.middle) + } + Text(targetLabel) + Text(deployment.timeAgo == "just now" ? "just now" : deployment.timeAgo + " ago") + } + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + HStack(spacing: 5) { + PanelLinkButton(title: "Open", accent: "#7C5CFF", help: deployment.url) { + if let url = safeWebURL("https://\(deployment.url)") { NSWorkspace.shared.open(url) } + } + if deployment.state == "READY" || deployment.state == "ERROR" { + PanelActionButton(title: "Redeploy", symbol: "arrow.clockwise", accent: "#7C5CFF", + help: "Build this deployment again", + perform: { + try await ServiceAPI.perform( + kind: "vercel.redeploy", + target: "\(deployment.id)|\(deployment.projectName)|\(deployment.target ?? "")") + }, + onResult: finished) + } + if deployment.isBuilding { + PanelActionButton(title: "Cancel build", symbol: "xmark.circle", accent: "#F4505E", + help: "Stop this build", + perform: { try await ServiceAPI.perform(kind: "vercel.cancel", target: deployment.id) }, + onResult: finished) + } + if deployment.state == "READY" && deployment.target != "production" { + PanelActionButton(title: "Promote", symbol: "arrow.up.circle", accent: "#22C55E", + help: "Start a production build from this deployment, with your production environment variables", + perform: { + try await ServiceAPI.perform( + kind: "vercel.promote", + target: "\(deployment.id)|\(deployment.projectName)") + }, + onResult: finished) + } + Spacer(minLength: 0) + } + .padding(.top, 1) + } + } + // Keyed on the state too: a build that fails while the panel is open gets its reason + .task(id: "\(deployment.id)|\(deployment.state)") { + guard deployment.state == "ERROR" else { return } + failureReason = nil + let reason = try? await ServiceAPI.vercelFailureReason(id: deployment.id) + failureReason = reason ?? "No error message from Vercel" + } + } +} + +// MARK: - Actions + +struct PanelStatus: Equatable { + let text: String + let ok: Bool + var neutral = false // information, neither a success nor a failure +} + +/// Thrown by an action that had nothing to do (e.g. no failed run to re-run): +/// the button goes back to idle and the message shows in grey. +struct PanelNotice: Error { + let message: String +} + +/// The last action's result, cleared after a few seconds. +@MainActor +final class PanelStatusModel: ObservableObject { + @Published var current: PanelStatus? = nil + private var clear: DispatchWorkItem? + + func show(_ text: String, ok: Bool, neutral: Bool = false) { + clear?.cancel() + withAnimation(.easeInOut(duration: 0.16)) { current = PanelStatus(text: text, ok: ok, neutral: neutral) } + let work = DispatchWorkItem { [weak self] in + withAnimation(.easeInOut(duration: 0.16)) { self?.current = nil } + } + clear = work + DispatchQueue.main.asyncAfter(deadline: .now() + (ok ? 4 : 8), execute: work) + } +} + +/// A small capsule button that acts on GitHub or Vercel. Nothing runs on the first click: +/// it turns into "Confirm?" for 3 seconds, and only a second click runs the action. +struct PanelActionButton: View { + let title: String + let symbol: String + let accent: String + var help: String? = nil + let perform: @MainActor () async throws -> String + let onResult: (String, Bool) -> Void + /// Shows a PanelNotice (nothing was done). Defaults to nothing. + var onNotice: (String) -> Void = { _ in } + + private enum Phase { case idle, confirming, running, done, failed } + @State private var phase: Phase = .idle + @State private var isHovered = false + @State private var reset: DispatchWorkItem? + + private var color: String { + switch phase { + case .idle, .running: return accent + case .confirming: return "#F5A524" + case .done: return "#22C55E" + case .failed: return "#F4505E" + } + } + + private func resetLater(_ seconds: Double) { + reset?.cancel() + let work = DispatchWorkItem { phase = .idle } + reset = work + DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work) + } + + private func tap() { + switch phase { + case .idle: + phase = .confirming + SoundEngine.shared.play("blip") + resetLater(3) + case .confirming: + reset?.cancel() + phase = .running + Task { @MainActor in + do { + let message = try await perform() + phase = .done + SoundEngine.shared.play("finish") + onResult(message, true) + } catch let notice as PanelNotice { + phase = .idle + SoundEngine.shared.play("blip") + onNotice(notice.message) + return + } catch { + phase = .failed + SoundEngine.shared.play("error") + onResult(ServiceAPI.describe(error), false) + } + resetLater(2.5) + } + case .running, .done, .failed: + break + } + } + + var body: some View { + Button(action: tap) { + HStack(spacing: 3) { + switch phase { + case .idle: + Image(systemName: symbol).font(.system(size: 7.5, weight: .bold)) + Text(title) + case .confirming: + Text("Confirm?") + case .running: + ProgressView().controlSize(.mini).scaleEffect(0.6).frame(width: 8, height: 8) + Text(title) + case .done: + Image(systemName: "checkmark").font(.system(size: 7.5, weight: .bold)) + Text("Done") + case .failed: + Image(systemName: "xmark").font(.system(size: 7.5, weight: .bold)) + Text("Failed") + } + } + .font(.system(size: 9.5, weight: .semibold)) + .foregroundColor(Color(hex: color).lighter(by: 0.25)) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 7) + .frame(height: 17) + .background(Capsule().fill(Color(hex: color).opacity(isHovered || phase == .confirming ? 0.24 : 0.13))) + .overlay(Capsule().stroke(Color(hex: color).opacity(0.35), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .layoutPriority(2) + .onHover { isHovered = $0 } + .help(phase == .confirming ? "Click again to confirm" : (help ?? title)) + .animation(.easeInOut(duration: 0.14), value: phase) + } +} + +/// Same shape as PanelActionButton for a plain link (no confirmation). +struct PanelLinkButton: View { + let title: String + let accent: String + var help: String? = nil + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Text(title) + Image(systemName: "arrow.up.right").font(.system(size: 7, weight: .bold)) + } + .font(.system(size: 9.5, weight: .semibold)) + .foregroundColor(Color(hex: isHovered ? "#C5C8CD" : "#8E939C")) + .fixedSize() + .padding(.horizontal, 7) + .frame(height: 17) + .background(Capsule().fill(Color.white.opacity(isHovered ? 0.12 : 0.06))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(help ?? title) + } +} + +// MARK: - Claude Code session → branch + +/// The chips after a session's name: its branch and PR. +struct SessionChips: View { + @ObservedObject var state: AppState + let taskId: String + + var body: some View { + if let git = state.sessionGit[taskId] { + SessionBranchChip(git: git, info: state.sessionBranchInfo(for: taskId), + isOpen: state.sidePanel == .sessionBranch(taskId)) { + state.toggleSidePanel(.sessionBranch(taskId)) + } + } + } +} + +/// Next to the session name: "#12" with the PR's CI dot, or the branch when it has no open PR. +struct SessionBranchChip: View { + let git: GitRepoInfo + let info: SessionBranchInfo? + let isOpen: Bool + let action: () -> Void + @State private var isHovered = false + + private var ci: CIState { info?.pr?.ci ?? info?.branchCI ?? .unknown } + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Circle() + .fill(ghCIDot(ci)) + .frame(width: 5, height: 5) + .opacity(ci == .unknown ? 0.35 : 1) + if let pr = info?.pr { + Text("#\(pr.number)") + } else { + Image(systemName: "arrow.branch").font(.system(size: 7, weight: .semibold)) + Text(git.branch) + .truncationMode(.middle) + .frame(maxWidth: 60, alignment: .leading) + } + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: isHovered || isOpen ? "#C5C8CD" : "#8E939C")) + .lineLimit(1) + .padding(.horizontal, 5) + .frame(height: 15) + .background(Capsule().fill(Color.white.opacity(isHovered || isOpen ? 0.12 : 0.06))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .fixedSize() + .onHover { isHovered = $0 } + .help(info?.pr.map { "\($0.title) · \(git.repo)" } ?? "\(git.repo) · \(git.branch)") + } +} + +struct SessionBranchPanel: View { + @ObservedObject var state: AppState + let taskId: String + let attention: Bool + let onClose: () -> Void + @StateObject private var status = PanelStatusModel() + + private var git: GitRepoInfo? { state.sessionGit[taskId] } + private var info: SessionBranchInfo? { state.sessionBranchInfo(for: taskId) } + + private func finished(_ message: String, _ ok: Bool) { + status.show(message, ok: ok) + guard ok, let git else { return } + SessionGitHubLinker.shared.invalidate(repo: git.repo, branch: git.branch) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + SessionGitHubLinker.shared.refresh(for: state.tasks.first { $0.id == taskId }) + GithubPoller.shared.triggerPulseNow() + } + } + + private func ciWord(_ ci: CIState) -> String { + switch ci { + case .failure: return "failing" + case .pending: return "running" + case .success: return "passing" + case .unknown: return "no checks" + } + } + + var body: some View { + let repoName = git.map { $0.repo.split(separator: "/").last.map(String.init) ?? $0.repo } ?? "Session" + SidePanelChrome(title: repoName, accent: "#F4505E", subtitle: git?.branch, status: status.current, + attention: attention, onClose: onClose) { + if let git { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 1) { + // Pull request + if let pr = info?.pr { + HStack(spacing: 6) { + GitHubPRRowView(pr: pr, showCI: true) + if !pr.isDraft { + PanelActionButton(title: "Merge", symbol: "arrow.triangle.merge", accent: "#A371F7", + help: "Squash and merge \(pr.id)", + perform: { try await ServiceAPI.perform(kind: "github.merge", target: pr.id) }, + onResult: finished) + } + } + } else { + HStack(spacing: 6) { + Text(info == nil ? "Looking for a pull request…" : "No open pull request") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + Spacer(minLength: 4) + if info != nil { + PanelLinkButton(title: "Open PR", accent: "#F4505E", + help: "Compare \(git.branch) on GitHub") { + let branch = git.branch.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? git.branch + if let url = safeWebURL("https://github.com/\(git.repo)/compare/\(branch)?expand=1") { + NSWorkspace.shared.open(url) + } + } + } + } + .frame(height: 20) + } + + // Branch checks (shown when there is no PR, or to re-run a failure) + if let info, info.pr == nil || info.branchCI == .failure { + HStack(spacing: 5) { + Circle().fill(ghCIDot(info.branchCI)).frame(width: 5, height: 5) + .opacity(info.branchCI == .unknown ? 0.35 : 1) + Text("Checks") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#9398A1")) + Text(ciWord(info.branchCI)) + .font(.system(size: 11)) + .foregroundColor(Color(hex: info.branchCI == .unknown ? "#6B7079" : "#C5C8CD")) + Spacer(minLength: 4) + if info.branchCI == .failure { + PanelActionButton(title: "Re-run", symbol: "arrow.clockwise", accent: "#F5A524", + help: "Re-run the failed jobs of the latest failed run on \(git.branch)", + perform: { + guard let run = try await ServiceAPI.latestFailedRun(repo: git.repo, branch: git.branch) else { + throw PanelNotice(message: "No failed run to re-run") + } + return try await ServiceAPI.perform(kind: "github.rerun", target: run) + }, + onResult: finished, + onNotice: { status.show($0, ok: true, neutral: true) }) + } + } + .frame(height: 20) + } + + // Vercel preview of this branch + if let dep = state.vercelPreview(repo: git.repo, branch: git.branch) { + HStack(spacing: 5) { + Circle().fill(Color(hex: dep.stateColor)).frame(width: 5, height: 5) + Text(dep.target == "production" ? "Production" : "Vercel") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#9398A1")) + Text("\(dep.statusLabel) · \(dep.isBuilding ? "now" : dep.timeAgo)") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1) + Spacer(minLength: 4) + PanelLinkButton(title: dep.target == "production" ? "Open" : "Preview", + accent: "#7C5CFF", help: dep.url) { + if let url = safeWebURL("https://\(dep.url)") { NSWorkspace.shared.open(url) } + } + } + .frame(height: 20) + } + } + } + } else { + Text("This session isn't in a GitHub checkout") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + } + } + .onAppear { SessionGitHubLinker.shared.refresh(for: state.tasks.first { $0.id == taskId }) } + } +} + +/// A small Vercel mark in the deployment's state color; opens the preview. +struct VercelPreviewDot: View { + let deployment: VercelDeployment + @State private var isHovered = false + + var body: some View { + Button(action: { + if let url = safeWebURL("https://\(deployment.url)") { NSWorkspace.shared.open(url) } + }) { + Image(systemName: "triangle.fill") + .font(.system(size: 6.5, weight: .bold)) + .foregroundColor(Color(hex: deployment.stateColor)) + .frame(width: 17, height: 17) + .background(Circle().fill(Color.white.opacity(isHovered ? 0.12 : 0.06))) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help("Vercel \(deployment.target == "production" ? "production" : "preview") · \(deployment.statusLabel) · \(deployment.url)") + } +} diff --git a/NotchBuddy/Sources/App/VercelPoller.swift b/NotchBuddy/Sources/App/VercelPoller.swift index 8cfe60888b..6558a92faa 100644 --- a/NotchBuddy/Sources/App/VercelPoller.swift +++ b/NotchBuddy/Sources/App/VercelPoller.swift @@ -1,5 +1,18 @@ import Foundation +// MARK: - VercelAPI + +enum VercelAPI { + /// Adds `teamId` to a Vercel API URL when a team scope is selected in Settings. + /// Reads UserDefaults directly so it is safe from any thread. + static func url(_ base: String) -> String { + guard let team = UserDefaults.standard.string(forKey: "vercelTeamId"), !team.isEmpty, + var components = URLComponents(string: base) else { return base } + components.queryItems = (components.queryItems ?? []) + [URLQueryItem(name: "teamId", value: team)] + return components.string ?? base + } +} + // MARK: - VercelPoller // Polls Vercel API for latest deployments every 30s. // On new terminal deployment: updates integration_vercel task state + AppState.vercelDeployments. @@ -8,6 +21,8 @@ final class VercelPoller: @unchecked Sendable { static let shared = VercelPoller() private var timer: DispatchSourceTimer? private var lastDeploymentId: String = "" + // Set when the scope changes: the next poll records the latest deployment without an alert. + private var silentNextPoll = false private init() {} @@ -20,14 +35,37 @@ final class VercelPoller: @unchecked Sendable { timer = t } + /// Called when the team scope changes: drops the old scope's deployments and polls again silently. + @MainActor + func scopeChanged() { + AppState.shared.vercelDeployments = [] + lastDeploymentId = "" + silentNextPoll = true + DispatchQueue.global(qos: .background).async { [weak self] in self?.poll() } + } + + /// Polls right away, e.g. after a redeploy from the notch. + func pollNow() { + DispatchQueue.global(qos: .background).async { [weak self] in self?.poll() } + } + // MARK: - Poll private func poll() { guard !DemoEngine.isPollerPaused else { return } guard let token = KeychainStore.shared.get("vercel-token") else { return } - - // Fetch last 5 terminal deployments - guard let url = URL(string: "https://api.vercel.com/v6/deployments?limit=5") else { return } + let wanted = DispatchQueue.main.sync { + MainActor.assumeIsolated { AppState.shared.activeIntegrations.contains("integration_vercel") } + } + #if PHONE_LINK + guard wanted || UserDefaults.standard.bool(forKey: "iPhoneSyncEnabled") else { return } + #else + guard wanted else { return } + #endif + + // Fetch the last 20 deployments (enough to cover a few watched projects) + let scope = UserDefaults.standard.string(forKey: "vercelTeamId") ?? "" + guard let url = URL(string: VercelAPI.url("https://api.vercel.com/v6/deployments?limit=20")) else { return } var req = URLRequest(url: url, timeoutInterval: 10) req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Accept") @@ -39,21 +77,24 @@ final class VercelPoller: @unchecked Sendable { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rawList = json["deployments"] as? [[String: Any]] else { return } - // Only terminal deployments (READY, ERROR, CANCELED) - let terminal = ["READY", "ERROR", "CANCELED"] + // Finished deployments (READY, ERROR, CANCELED) and builds in progress let parsed = rawList .compactMap { self.parseDeployment($0) } - .filter { terminal.contains($0.state) } + .filter { $0.isTerminal || $0.isBuilding } guard !parsed.isEmpty else { return } - DispatchQueue.main.async { self.handleDeployments(parsed) } + DispatchQueue.main.async { + // The scope changed while this request was in flight: its deployments are another team's + guard scope == (UserDefaults.standard.string(forKey: "vercelTeamId") ?? "") else { return } + self.handleDeployments(parsed) + } }.resume() } private func parseDeployment(_ d: [String: Any]) -> VercelDeployment? { guard let uid = d["uid"] as? String, let name = d["name"] as? String, - let state = d["state"] as? String else { return nil } + let state = (d["state"] as? String) ?? (d["readyState"] as? String) else { return nil } let url = (d["url"] as? String) ?? "" let createdAtMs = (d["createdAt"] as? Double) ?? 0 @@ -68,7 +109,23 @@ final class VercelPoller: @unchecked Sendable { ?? meta?["bitbucketBranch"] as? String return VercelDeployment(id: uid, projectName: name, url: url, state: state, - createdAt: createdAt, commitMessage: commitMessage, branch: branch) + createdAt: createdAt, commitMessage: commitMessage, branch: branch, + target: d["target"] as? String, + repo: Self.repo(fromMeta: meta)) + } + + /// "org/repo" from a deployment's git metadata (GitHub, GitLab, Bitbucket). + static func repo(fromMeta meta: [String: Any]?) -> String? { + guard let meta else { return nil } + let pairs = [("githubCommitOrg", "githubCommitRepo"), ("githubOrg", "githubRepo"), + ("gitlabProjectNamespace", "gitlabProjectName"), + ("bitbucketRepoOwner", "bitbucketRepoName")] + for (o, r) in pairs { + if let org = meta[o] as? String, let name = meta[r] as? String, !org.isEmpty, !name.isEmpty { + return "\(org)/\(name)" + } + } + return nil } @MainActor @@ -76,12 +133,12 @@ final class VercelPoller: @unchecked Sendable { let appState = AppState.shared appState.vercelDeployments = deployments - // Apply project filter (empty = all projects) - let filter = appState.vercelProjectFilter - let filtered = filter.isEmpty ? deployments : deployments.filter { filter.contains($0.projectName) } - guard let latest = filtered.first else { return } + // Alert once per finished deployment of a watched project (empty filter = all projects). + // A build in progress alerts when it finishes, as it becomes the latest finished one. + guard let latest = appState.vercelWatchedDeployments.first(where: \.isTerminal) else { return } guard latest.id != lastDeploymentId else { return } lastDeploymentId = latest.id + if silentNextPoll { silentNextPoll = false; return } guard let idx = appState.tasks.firstIndex(where: { $0.id == "integration_vercel" }) else { return } let focused = appState.focusId == "integration_vercel" diff --git a/scripts/test-git-repo-info.sh b/scripts/test-git-repo-info.sh new file mode 100755 index 0000000000..3a43eae14f --- /dev/null +++ b/scripts/test-git-repo-info.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-git-repo-info.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/GitRepoInfo.swift \ + tests/GitRepoInfoTests.swift -o "$TEST_DIR/git-repo-info-tests" +"$TEST_DIR/git-repo-info-tests" diff --git a/tests/GitHubPulseTests.swift b/tests/GitHubPulseTests.swift index b3d7423654..a3f8704394 100644 --- a/tests/GitHubPulseTests.swift +++ b/tests/GitHubPulseTests.swift @@ -343,6 +343,85 @@ enum GitHubPulseTests { GitHubPulse.isStale(fetchedAt: now.addingTimeInterval(-61), now: now, maxAge: 60)) } + // ── GitHubPulse.parse — watched repos as aliases r0, r1… ───────────── + print("GitHubPulse.parse — watched repo aliases") + do { + let json = """ + { + "data": { + "viewer": { "login": "u", "pullRequests": { "nodes": [] } }, + "reviewRequested": { "issueCount": 0, "nodes": [] }, + "r1": { "nameWithOwner": "org/second", "url": "u2", "isArchived": false, + "defaultBranchRef": { "name": "main", + "target": { "oid": "b", "statusCheckRollup": { "state": "FAILURE" } } } }, + "r0": { "nameWithOwner": "org/first", "url": "u1", "isArchived": false, + "defaultBranchRef": { "name": "dev", + "target": { "oid": "a", "statusCheckRollup": { "state": "SUCCESS" } } } }, + "r2": null + } + } + """.data(using: .utf8)! + let pulse = GitHubPulse.parse(json) + check("parses with aliases", pulse != nil) + check("aliases in r0, r1 order", pulse?.mainCI.map(\.repo) == ["org/first", "org/second"]) + check("alias CI parsed", pulse?.mainCI.map(\.ci) == [.success, .failure]) + check("null alias (no access) skipped", pulse?.mainCI.count == 2) + } + + // ── GitHubPulse.filtered ────────────────────────────────────────────── + print("GitHubPulse.filtered") + do { + var pulse = GitHubPulse.parse(emptyJSON)! + pulse.myPRs = [GitHubPR(id: "a/x#1", title: "", url: "", repo: "a/x", number: 1, + isDraft: false, ci: .success, review: .unknown), + GitHubPR(id: "b/y#2", title: "", url: "", repo: "b/y", number: 2, + isDraft: false, ci: .failure, review: .unknown)] + pulse.toReview = [GitHubPR(id: "b/y#3", title: "", url: "", repo: "b/y", number: 3, + isDraft: false, ci: .unknown, review: .pending)] + pulse.mainCI = [GitHubRepoCI(repo: "a/x", url: "", branch: "main", ci: .success), + GitHubRepoCI(repo: "b/y", url: "", branch: "main", ci: .failure)] + check("empty set → unchanged", pulse.filtered(toRepos: []) == pulse) + let only = pulse.filtered(toRepos: ["b/y"]) + check("PRs narrowed", only.myPRs.map(\.repo) == ["b/y"]) + check("to-review narrowed", only.toReview.count == 1) + check("main CI narrowed", only.mainCI.map(\.repo) == ["b/y"]) + check("login kept", only.login == pulse.login) + } + + // ── SessionBranchInfo.parse ─────────────────────────────────────────── + print("SessionBranchInfo.parse") + do { + func json(_ prs: String) -> Data { + """ + { "data": { "repository": { + "pullRequests": { "nodes": [\(prs)] }, + "ref": { "target": { "oid": "x", "statusCheckRollup": { "state": "PENDING" } } } + } } } + """.data(using: .utf8)! + } + let fork = """ + { "number": 152, "title": "From a fork", "url": "u", "isDraft": false, "headRefName": "main", + "headRepository": { "nameWithOwner": "someone/coucou" }, "commits": { "nodes": [] } } + """ + let own = """ + { "number": 7, "title": "Mine", "url": "u7", "isDraft": true, "reviewDecision": "APPROVED", + "headRefName": "main", "headRepository": { "nameWithOwner": "Me/App" }, + "commits": { "nodes": [ { "commit": { "oid": "h", "statusCheckRollup": { "state": "FAILURE" } } } ] } } + """ + let onlyFork = SessionBranchInfo.parse(json(fork), repo: "me/app", branch: "main") + check("fork PR with same branch name ignored", onlyFork != nil && onlyFork?.pr == nil) + check("branch CI parsed", onlyFork?.branchCI == .pending) + check("hasPending from branch CI", onlyFork?.hasPending == true) + let both = SessionBranchInfo.parse(json(fork + "," + own), repo: "me/app", branch: "main") + check("own PR found (case-insensitive repo)", both?.pr?.number == 7) + check("own PR CI / draft / review", both?.pr?.ci == .failure && both?.pr?.isDraft == true + && both?.pr?.review == .approved) + check("PR id uses session repo", both?.pr?.id == "me/app#7") + check("variables", SessionBranchInfo.variables(repo: "o/n", branch: "feat/x") + == ["owner": "o", "name": "n", "branch": "feat/x", "qualified": "refs/heads/feat/x"]) + check("bad repo → no variables", SessionBranchInfo.variables(repo: "nope", branch: "b") == nil) + } + // ── finish ───────────────────────────────────────────────────────────── if failures == 0 { print("\nAll tests passed.") diff --git a/tests/GitRepoInfoTests.swift b/tests/GitRepoInfoTests.swift new file mode 100644 index 0000000000..9525253093 --- /dev/null +++ b/tests/GitRepoInfoTests.swift @@ -0,0 +1,64 @@ +import Foundation + +@main +enum GitRepoInfoTests { + static var failures = 0 + + static func check(_ label: String, _ got: Bool) { + if got { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func main() { + print("GitRepoInfo.githubRepo") + check("https", GitRepoInfo.githubRepo(fromRemote: "https://github.com/o/n.git") == "o/n") + check("https no .git", GitRepoInfo.githubRepo(fromRemote: "https://github.com/o/n") == "o/n") + check("scp-like ssh", GitRepoInfo.githubRepo(fromRemote: "git@github.com:o/n.git") == "o/n") + check("ssh url", GitRepoInfo.githubRepo(fromRemote: "ssh://git@github.com/o/n.git") == "o/n") + check("other host → nil", GitRepoInfo.githubRepo(fromRemote: "https://gitlab.com/o/n.git") == nil) + check("too deep → nil", GitRepoInfo.githubRepo(fromRemote: "https://github.com/o/n/x") == nil) + + print("GitRepoInfo.branch") + check("simple", GitRepoInfo.branch(fromHEAD: "ref: refs/heads/main\n") == "main") + check("with slash", GitRepoInfo.branch(fromHEAD: "ref: refs/heads/feat/x") == "feat/x") + check("detached → nil", GitRepoInfo.branch(fromHEAD: "0123456789abcdef\n") == nil) + + print("GitRepoInfo.originURL") + let config = """ + [core] + \tbare = false + [remote "upstream"] + \turl = https://github.com/a/up.git + [remote "origin"] + \turl = git@github.com:me/fork.git + \tfetch = +refs/heads/*:refs/remotes/origin/* + """ + check("picks origin", GitRepoInfo.originURL(fromConfig: config) == "git@github.com:me/fork.git") + check("no origin → nil", GitRepoInfo.originURL(fromConfig: "[core]\n\tbare = false") == nil) + + print("GitRepoInfo.read (temp checkout and worktree)") + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("gri-\(UUID().uuidString)") + defer { try? fm.removeItem(at: root) } + let main = root.appendingPathComponent("main") + let git = main.appendingPathComponent(".git") + try? fm.createDirectory(at: git.appendingPathComponent("worktrees/wt"), withIntermediateDirectories: true) + try? fm.createDirectory(at: main.appendingPathComponent("src/deep"), withIntermediateDirectories: true) + try? "ref: refs/heads/dev\n".write(to: git.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + try? "[remote \"origin\"]\n\turl = https://github.com/me/app.git\n" + .write(to: git.appendingPathComponent("config"), atomically: true, encoding: .utf8) + check("from a subfolder", + GitRepoInfo.read(cwd: main.appendingPathComponent("src/deep").path) == GitRepoInfo(repo: "me/app", branch: "dev")) + let wt = root.appendingPathComponent("wt") + try? fm.createDirectory(at: wt, withIntermediateDirectories: true) + try? "gitdir: \(git.path)/worktrees/wt\n".write(to: wt.appendingPathComponent(".git"), atomically: true, encoding: .utf8) + try? "ref: refs/heads/feature/x\n".write(to: git.appendingPathComponent("worktrees/wt/HEAD"), atomically: true, encoding: .utf8) + try? "../..\n".write(to: git.appendingPathComponent("worktrees/wt/commondir"), atomically: true, encoding: .utf8) + check("worktree: own branch, main repo's remote", + GitRepoInfo.read(cwd: wt.path) == GitRepoInfo(repo: "me/app", branch: "feature/x")) + check("not a repo → nil", GitRepoInfo.read(cwd: root.path) == nil) + + if failures == 0 { print("\nAll tests passed."); exit(0) } + print("\n\(failures) test(s) failed."); exit(1) + } +} From a5e52d7f8b8c56d545c2d9a45ffced96ac5664a8 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 21:55:25 -0600 Subject: [PATCH 2/6] =?UTF-8?q?Show=20Claude=20Code=20sessions=20from=20te?= =?UTF-8?q?rminals=20(Warp,=20Terminal,=20iTerm=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClaudeHost: recognise terminal sessions from the hook's term_program / bundle_id and route them to the Claude Code pill (AgentTask.hostApp). - The pill says "Claude Code" and "Open " brings it forward; "Handled in …" notes name the right app. - New setting, off by default: answer terminal sessions' questions and permissions in the notch. Off, the terminal asks itself straight away. - Tests: scripts/test-claude-host.sh. Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 6 ++ NotchBuddy/Sources/App/ClaudeHost.swift | 75 +++++++++++++++++++ NotchBuddy/Sources/App/HookServer.swift | 46 +++++++++--- .../Sources/App/IslandViewContent.swift | 15 +++- NotchBuddy/Sources/App/SettingsView.swift | 6 ++ .../Sources/CoucouKit/IslandTypes.swift | 1 + scripts/test-claude-host.sh | 8 ++ tests/ClaudeHostTests.swift | 40 ++++++++++ 8 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 NotchBuddy/Sources/App/ClaudeHost.swift create mode 100755 scripts/test-claude-host.sh create mode 100644 tests/ClaudeHostTests.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index fcd1ac75c3..2017fb10b4 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 875FB3F71BD34B53B0524063 /* CoucouWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6723392BE01AF210E484316 /* CoucouWidgets.swift */; }; 878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; }; 8881BC510155DEC038F3B552 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; + 88A88E8704759E7F1F9D52BA /* ClaudeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */; }; 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 8AC19BAF88822CB31519D447 /* CoucouIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708E1FD9894461B4AD727DB1 /* CoucouIntents.swift */; }; 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; @@ -232,6 +233,7 @@ F28275101D83F121E582B1B1 /* LiveActivityRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */; }; F5D6F8E2CA65AB16B487DDD0 /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; F6CA56F362902839EF9A3E2E /* PhoneAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 60401D1023D17C3FD30D7113 /* PhoneAssets.xcassets */; }; + F6D57EEBD7E937EFD2B715CF /* ClaudeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */; }; F9445A71BBF9E4D20224238E /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; FAD78D74C6906D4EC8AA4101 /* UploadSequenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */; }; FB063FE6B8A4BB8FBAAF56CB /* LiveActivityLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA2D136C81C6D930C5357BD /* LiveActivityLink.swift */; }; @@ -365,6 +367,7 @@ C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnRecorder.swift; sourceTree = ""; }; C2E4F18AA940EFF7E047B9F7 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingCanvasView.swift; sourceTree = ""; }; + D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHost.swift; sourceTree = ""; }; D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenLeadMochiIntent.swift; sourceTree = ""; }; D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CoucouNotificationContent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D710BD7834228045869D0184 /* IslandWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandWindowController.swift; sourceTree = ""; }; @@ -415,6 +418,7 @@ 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, + D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */, F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */, 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, @@ -789,6 +793,7 @@ 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */, 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */, 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */, + 88A88E8704759E7F1F9D52BA /* ClaudeHost.swift in Sources */, 94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */, D861479F6E7EAD07A6AC2105 /* ClaudePlanGauge.swift in Sources */, 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */, @@ -939,6 +944,7 @@ 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */, B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */, 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */, + F6D57EEBD7E937EFD2B715CF /* ClaudeHost.swift in Sources */, 18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */, E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */, 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */, diff --git a/NotchBuddy/Sources/App/ClaudeHost.swift b/NotchBuddy/Sources/App/ClaudeHost.swift new file mode 100644 index 0000000000..7d846d7990 --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudeHost.swift @@ -0,0 +1,75 @@ +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". + +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", + ] + 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 + } +} diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 5084320c0e..e3bd619d99 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -368,6 +368,7 @@ final class HookServer: @unchecked Sendable { // • other valid coucou_agent → external pill (fire-and-forget, no approval card) // • Cursor bundle ID → agent_cursor // • VS Code → integration_claude + // • a known terminal (Warp, Terminal, iTerm…) → integration_claude, host recorded on the task #if !APPSTORE let isCodexEvent = rawAgent == "codex" #else @@ -375,6 +376,7 @@ final class HookServer: @unchecked Sendable { #endif let agentId: String let isExternalAgent: Bool + var hostApp: String? = nil if isCodexEvent { agentId = "agent_codex" isExternalAgent = false @@ -387,6 +389,10 @@ final class HookServer: @unchecked Sendable { } else if isVSCodeEditor { agentId = "integration_claude" isExternalAgent = false + } else if let host = ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) { + agentId = "integration_claude" + isExternalAgent = false + hostApp = host.bundleId } else { nbLog("Ignored \(name) from \(termProgram.isEmpty ? bundleId : termProgram) (\(projectName))") return @@ -414,7 +420,7 @@ final class HookServer: @unchecked Sendable { case "agent_codex": handledNote = "Handled in Codex." case "agent_copilot": handledNote = "Handled in Copilot CLI." case "agent_muse": handledNote = "Handled in Muse Code." - default: handledNote = "Handled in VS Code." + default: handledNote = "Handled in \(claudeHostName)." } var resolved = false switch name { @@ -443,7 +449,7 @@ final class HookServer: @unchecked Sendable { case "SessionStart": activeSessionId = sessionId - if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd, hostApp: hostApp) } if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } nbLog("SessionStart \(isExternalAgent ? agentId : projectName) (\(sessionId.prefix(8)))") NotificationCenter.default.post(name: .checkMondayRecap, object: nil) @@ -452,7 +458,7 @@ final class HookServer: @unchecked Sendable { case "UserPromptSubmit": activeSessionId = sessionId - if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd, hostApp: hostApp) } if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } state.updateTask(id: agentId, state: .thinking) if let prompt = payload["prompt"] as? String, !prompt.isEmpty { @@ -470,7 +476,7 @@ final class HookServer: @unchecked Sendable { // AskUserQuestion is handled via the dedicated --ask hook. // Skip state/step update here to avoid flickering over the question card. guard tool != "AskUserQuestion" else { break } - if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd, hostApp: hostApp) } state.updateTask(id: agentId, state: .working) let input = payload["tool_input"] as? [String: Any] ?? [:] let step = localizedStep(tool: tool, input: input) @@ -690,7 +696,11 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - guard isCodexRequest || isCopilotRequest || isMuseRequest || isCursorEditor || isVSCodeEditor else { + // Terminal sessions: only when turned on in Settings, else the terminal asks itself. + let terminalHost = isCursorEditor || isVSCodeEditor ? nil + : ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) + let isTerminal = terminalHost != nil && ClaudeHost.terminalCardsEnabled + guard isCodexRequest || isCopilotRequest || isMuseRequest || isCursorEditor || isVSCodeEditor || isTerminal else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) @@ -731,7 +741,7 @@ final class HookServer: @unchecked Sendable { pendingApprovalFD = fd activeSessionId = sessionId - upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) + upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd, hostApp: terminalHost?.bundleId) state.updateTask(id: pillId, state: .approval) state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, command: command, inputKey: inputKey, pillId: pillId) @@ -756,7 +766,7 @@ final class HookServer: @unchecked Sendable { case "agent_codex": note = "Handled in Codex." case "agent_copilot": note = "Handled in Copilot CLI." case "agent_muse": note = "Handled in Muse Code." - default: note = "Handled in VS Code." + default: note = "Handled in \(self.claudeHostName)." } self.dismissApprovalCard(note: note) } @@ -777,7 +787,7 @@ final class HookServer: @unchecked Sendable { case "agent_codex": note = "Still waiting in Codex." case "agent_copilot": note = "Still waiting in Copilot CLI." case "agent_muse": note = "Still waiting in Muse Code." - default: note = "Still waiting in VS Code." + default: note = "Still waiting in \(self.claudeHostName)." } self.dismissApprovalCard(note: note) } @@ -871,7 +881,11 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - guard isCodexRequest || isCursorEditor || isVSCodeEditor else { + // Terminal sessions: only when turned on in Settings, else the terminal asks itself. + let terminalHost = isCursorEditor || isVSCodeEditor ? nil + : ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) + let isTerminal = terminalHost != nil && ClaudeHost.terminalCardsEnabled + guard isCodexRequest || isCursorEditor || isVSCodeEditor || isTerminal else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) @@ -896,7 +910,7 @@ final class HookServer: @unchecked Sendable { ? "\(pillId)+\(cwd)" : sessionId - upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) + upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd, hostApp: terminalHost?.bundleId) state.updateTask(id: pillId, state: .question) state.pendingQuestion = parsed state.isPinned = true @@ -931,23 +945,31 @@ final class HookServer: @unchecked Sendable { } } + /// "VS Code", "Warp"… — where the Claude Code pill's current session runs. + @MainActor + private var claudeHostName: String { + ClaudeHost.name(for: AppState.shared.tasks.first { $0.id == "integration_claude" }?.hostApp) + } + /// Updates or transiently creates a workspace pill (VS Code or Cursor) task. /// If the task already exists (persistent), just updates name/cwd. /// If missing (transient), creates it and inserts after the main pill. @MainActor - private func upsertWorkspaceTask(id: String, projectName: String, cwd: String = "") { + private func upsertWorkspaceTask(id: String, projectName: String, cwd: String = "", hostApp: String? = nil) { let state = AppState.shared if let idx = state.tasks.firstIndex(where: { $0.id == id }) { state.tasks[idx].name = projectName if !cwd.isEmpty { state.tasks[idx].sessionCwd = cwd } + if id == "integration_claude" { state.tasks[idx].hostApp = hostApp } return } // Transient: create and insert after the main pill let def = PillCatalog.definition(for: id) let color = def?.color ?? "#C0C4CC" let source = def?.source ?? .agent - let task = AgentTask(id: id, name: projectName, color: color, + var task = AgentTask(id: id, name: projectName, color: color, state: .idle, steps: [], source: source, isIntegration: true) + if id == "integration_claude" { task.hostApp = hostApp } if let mainIdx = state.tasks.firstIndex(where: { $0.id == state.mainPillId }) { state.tasks.insert(task, at: mainIdx + 1) } else { diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index fe623cfa2c..ce9a995de8 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -182,6 +182,7 @@ struct OverviewView: View { guard let task else { return } switch task.id { case "integration_claude": + if ClaudeHost.activate(task.hostApp) { return } let vscodeBundleId = "com.microsoft.VSCode" if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == vscodeBundleId }) { app.activate(options: .activateIgnoringOtherApps) @@ -1897,7 +1898,8 @@ struct IntegrationCardView: View { Circle() .fill(Color(hex: task.color)) .frame(width: 7, height: 7) - Text(PillCatalog.definition(for: task.id)?.name ?? task.name) + Text(task.id == "integration_claude" ? ClaudeHost.pillName(hostApp: task.hostApp) + : PillCatalog.definition(for: task.id)?.name ?? task.name) .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) Text(PillCatalog.definition(for: task.id)?.subtitle ?? "Integration") @@ -1919,7 +1921,12 @@ struct IntegrationCardView: View { .padding(.top, 2) HStack(spacing: 8) { - if task.id == "integration_claude" { + if task.id == "integration_claude", task.hostApp != nil { + Button("Open \(ClaudeHost.name(for: task.hostApp))") { ClaudeHost.activate(task.hostApp) } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.7)) + .buttonStyle(.plain) + } else if task.id == "integration_claude" { Button("Open Visual Studio Code") { openVSCode() } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: task.color).opacity(0.7)) @@ -3736,9 +3743,9 @@ struct AgentPill: View { private var effectiveColor: String { task.color } - // VS Code pill always shows "VS Code" label regardless of active project name + // The Claude pill shows "VS Code" (or "Claude Code" for a terminal session) regardless of project name private var displayName: String { - task.id == "integration_claude" ? "VS Code" : task.name + task.id == "integration_claude" ? ClaudeHost.pillName(hostApp: task.hostApp) : task.name } var body: some View { diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 5fea56ff22..56fc883433 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -121,6 +121,7 @@ struct SettingsView: View { @AppStorage("iPhoneLiveActivityEnabled") private var iPhoneLiveActivityEnabled = false @AppStorage("iPhoneInstructionsEnabled") private var iPhoneInstructionsEnabled = false #endif + @AppStorage(ClaudeHost.terminalCardsKey) private var terminalCardsEnabled = false private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" @@ -502,6 +503,11 @@ struct SettingsView: View { .buttonStyle(.bordered) } #endif + Toggle("Answer questions and permissions from terminal sessions in the notch", isOn: $terminalCardsEnabled) + Text("Off: sessions in Warp, Terminal, iTerm… show in the notch, but their questions and permission requests are asked in the terminal. On: the notch shows them first, and the terminal waits until you answer there or close the island (up to 2 min).") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) #if !APPSTORE if showDiff { diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 0c435d0a74..b384c20c79 100644 --- a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -60,6 +60,7 @@ struct AgentTask: Identifiable, Equatable { var pillBadge: PillBadge? = nil // alert badge shown on pill when not focused var sessionCwd: String? = nil // last known working directory (Claude Code sessions) var finalLine: String? = nil // last assistant message shown as static text after Stop + var hostApp: String? = nil // bundle id of the terminal running a Claude Code session; nil = VS Code } enum AgentSource: Equatable { diff --git a/scripts/test-claude-host.sh b/scripts/test-claude-host.sh new file mode 100755 index 0000000000..a51a8e8b27 --- /dev/null +++ b/scripts/test-claude-host.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-claude-host.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/ClaudeHost.swift \ + tests/ClaudeHostTests.swift -o "$TEST_DIR/claude-host-tests" +"$TEST_DIR/claude-host-tests" diff --git a/tests/ClaudeHostTests.swift b/tests/ClaudeHostTests.swift new file mode 100644 index 0000000000..5c11b3d7a7 --- /dev/null +++ b/tests/ClaudeHostTests.swift @@ -0,0 +1,40 @@ +import Foundation + +@main +enum ClaudeHostTests { + static var failures = 0 + + static func check(_ label: String, _ got: Bool) { + if got { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func main() { + print("ClaudeHost.terminal") + check("Warp by bundle id", ClaudeHost.terminal(termProgram: "", bundleId: "dev.warp.Warp-Stable")?.name == "Warp") + check("Warp by TERM_PROGRAM", ClaudeHost.terminal(termProgram: "WarpTerminal", bundleId: "")?.bundleId == "dev.warp.Warp-Stable") + check("Terminal.app", ClaudeHost.terminal(termProgram: "Apple_Terminal", bundleId: "")?.name == "Terminal") + check("iTerm", ClaudeHost.terminal(termProgram: "iTerm.app", bundleId: "")?.name == "iTerm") + check("tmux inside Ghostty: bundle id wins", + ClaudeHost.terminal(termProgram: "tmux", bundleId: "com.mitchellh.ghostty")?.name == "Ghostty") + check("VS Code → nil", ClaudeHost.terminal(termProgram: "vscode", bundleId: "com.microsoft.VSCode") == nil) + check("Cursor → nil", ClaudeHost.terminal(termProgram: "vscode", bundleId: "com.todesktop.230313mzl4w4u92") == nil) + check("unknown → nil", ClaudeHost.terminal(termProgram: "", bundleId: "com.example.app") == nil) + + print("ClaudeHost.name / pillName") + check("nil host = VS Code", ClaudeHost.name(for: nil) == "VS Code") + check("Warp host", ClaudeHost.name(for: "dev.warp.Warp-Stable") == "Warp") + check("unknown host = VS Code", ClaudeHost.name(for: "com.example.app") == "VS Code") + check("editor session → VS Code", ClaudeHost.pillName(hostApp: nil) == "VS Code") + check("terminal session → Claude Code", ClaudeHost.pillName(hostApp: "dev.warp.Warp-Stable") == "Claude Code") + + print("ClaudeHost.terminalCardsEnabled") + UserDefaults.standard.removeObject(forKey: ClaudeHost.terminalCardsKey) + check("off by default", ClaudeHost.terminalCardsEnabled == false) + + if failures > 0 { + print("\n\(failures) test(s) failed."); exit(1) + } + print("\nAll tests passed.") + } +} From 3642bb9513a958a26a28a5ca05ac556ca7ecf3b5 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 21:59:12 -0600 Subject: [PATCH 3/6] Reply to a Claude Code session from the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The hooks remember the last session on each pill (ClaudeSessionRef); a ↰ chip next to the session opens a reply panel in the right card. - SessionResumer runs `claude -p --resume` in the session's folder, shared with the iPhone InstructionRunner (moved out of PhoneLink). - The island waits to collapse while the reply panel is open, takes key focus for the text field, and lets ⌘+arrows move the cursor there. - GitHub build only (not in the App Store build). Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 6 + NotchBuddy/Sources/App/AppState.swift | 3 + NotchBuddy/Sources/App/ClaudeHost.swift | 7 + NotchBuddy/Sources/App/HookServer.swift | 7 + .../Sources/App/IslandStateMachine.swift | 3 + .../Sources/App/IslandWindowController.swift | 14 ++ .../App/PhoneLink/InstructionRunner.swift | 81 +---------- .../Sources/App/PhoneLink/TurnRecorder.swift | 9 -- NotchBuddy/Sources/App/SessionResumer.swift | 113 +++++++++++++++ NotchBuddy/Sources/App/SidePanelView.swift | 136 +++++++++++++++++- 10 files changed, 292 insertions(+), 87 deletions(-) create mode 100644 NotchBuddy/Sources/App/SessionResumer.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 4b0c282bde..ec11649cd5 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 1FE62D4EEA85FB25C17FDBA2 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; 2104B8000768F551064B17E4 /* PhoneLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = C22AF9F301D7A23DA6254D49 /* PhoneLink.swift */; }; 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; + 220F9D1D4003ED72CE5DF282 /* SessionResumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49393F75E83B2BF1EA7C53C8 /* SessionResumer.swift */; }; 23BFB915C12B0BC4E9B50AAA /* ApprovalRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD426E81DF79CA79888059D9 /* ApprovalRelay.swift */; }; 244EC9B77D1903C8DD505D04 /* MochiLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA6A002ABEC0CC57667FBE0 /* MochiLiveActivity.swift */; }; 25F66E99460D62478899A292 /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; @@ -119,6 +120,7 @@ 8173F93555ED400954AA1CB3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097F57179CC066459E64303A /* AboutView.swift */; }; 818E87607FD9F0CA5406BF35 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B2AA83D80260660475FAB052 /* Localizable.xcstrings */; }; 82E72E0AFCAB192D98AB2004 /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + 8391B160C85758CBB9E26F08 /* SessionResumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49393F75E83B2BF1EA7C53C8 /* SessionResumer.swift */; }; 845D762E9F4F0AD0D5FCEDF4 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; 862CE6CB220D55E46F646DA9 /* GitHubPulse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */; }; 868E3EB4A275AA2394EA20BF /* CoucouWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5C3E2269A202B640E7F3B5E0 /* CoucouWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -314,6 +316,7 @@ 37147F42613183DBA8896835 /* finish.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = finish.wav; sourceTree = ""; }; 38BEAB5F20A13E5806A42EC9 /* ApprovalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalCard.swift; sourceTree = ""; }; 3A27178AA6007F7B56901750 /* SessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDetailView.swift; sourceTree = ""; }; + 49393F75E83B2BF1EA7C53C8 /* SessionResumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionResumer.swift; sourceTree = ""; }; 49E39852175E81C9632A59C9 /* KitPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KitPreviewView.swift; sourceTree = ""; }; 4DC2A07E42245A8CF3BBD2AB /* CoucouAppStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoucouAppStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4F142FB3952B5F0639383047 /* MusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicController.swift; sourceTree = ""; }; @@ -459,6 +462,7 @@ B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */, 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */, + 49393F75E83B2BF1EA7C53C8 /* SessionResumer.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, @@ -857,6 +861,7 @@ 0393CA5466F52252378F0346 /* ServiceSnapshot.swift in Sources */, 8C9878C52623BABE6FEF64C0 /* SessionGitHubLinker.swift in Sources */, 9728564FCBF0334E68E9E6E7 /* SessionPublisher.swift in Sources */, + 8391B160C85758CBB9E26F08 /* SessionResumer.swift in Sources */, 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */, C07E9A04E827284FDF33DB16 /* ShortcutLogic.swift in Sources */, 1EAFEE80A8066B77F9410025 /* ShortcutsSettingsView.swift in Sources */, @@ -1012,6 +1017,7 @@ 1FE62D4EEA85FB25C17FDBA2 /* ServiceSnapshot.swift in Sources */, 8A6809F8D00B59A697AAC90E /* SessionGitHubLinker.swift in Sources */, DE66BB6D18F467A7F4220CC3 /* SessionPublisher.swift in Sources */, + 220F9D1D4003ED72CE5DF282 /* SessionResumer.swift in Sources */, 8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */, 6CCEC73ED68E9F253C22D692 /* ShortcutLogic.swift in Sources */, 908043F62E2C31F5FBC0A0C4 /* ShortcutsSettingsView.swift in Sources */, diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 96d871de85..c5c58c070b 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -307,6 +307,8 @@ final class AppState: ObservableObject { // 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 { @@ -783,6 +785,7 @@ enum SidePanel: Equatable { 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 diff --git a/NotchBuddy/Sources/App/ClaudeHost.swift b/NotchBuddy/Sources/App/ClaudeHost.swift index 7d846d7990..e8dff81bfa 100644 --- a/NotchBuddy/Sources/App/ClaudeHost.swift +++ b/NotchBuddy/Sources/App/ClaudeHost.swift @@ -6,6 +6,13 @@ import AppKit // 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 diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index e3bd619d99..7994929102 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -405,6 +405,13 @@ final class HookServer: @unchecked Sendable { ? "\(agentId)+\(cwd)" : sessionId + // Remember the session so it can be continued from the notch or the iPhone. + if !isExternalAgent, !isCodexEvent, sessionId != "unknown", !cwd.isEmpty { + let ref = ClaudeSessionRef(sessionId: sessionId, cwd: cwd, + hostApp: agentId == "integration_claude" ? hostApp : nil) + if state.claudeSessions[agentId] != ref { state.claudeSessions[agentId] = ref } + } + #if PHONE_LINK // The iPhone's "last turn" (prompt, actions, diffs, answer). if !isExternalAgent { TurnRecorder.shared.record(event: name, payload: payload, pillId: agentId) } diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift index a8fe33191a..ed04285cec 100644 --- a/NotchBuddy/Sources/App/IslandStateMachine.swift +++ b/NotchBuddy/Sources/App/IslandStateMachine.swift @@ -19,6 +19,8 @@ final class IslandStateMachine { /// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island. var isHeldOpen: (() -> Bool)? + /// The user is writing in the island (reply panel): the auto-collapse waits instead of firing. + var isTyping: (() -> Bool)? /// home → petit delay (seconds). Override for debug. var homeToPetitDelay: TimeInterval = 15 @@ -162,6 +164,7 @@ final class IslandStateMachine { homeCollapseWork?.cancel() let item = DispatchWorkItem { [weak self] in guard let self, self.state == .home, !(self.isHeldOpen?() ?? false) else { return } + if self.isTyping?() == true { self.scheduleHomeCollapse(); return } self.transition(to: .petit) } homeCollapseWork = item diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index 4ae8a4ba94..dd09e092b0 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -14,6 +14,7 @@ final class IslandWindowController: NSWindowController { private var wasInIsland = false private var frameTimer: Timer? private var keyMonitor: Any? + private var sidePanelSubscription: AnyCancellable? private var viewSubscription: AnyCancellable? // Confused recovery timer (set by handleDizzy) @@ -158,6 +159,12 @@ final class IslandWindowController: NSWindowController { self.islandPanel.makeKey() } } + // Same for the reply panel's text field + sidePanelSubscription = state.$sidePanel + .receive(on: DispatchQueue.main) + .sink { [weak self] panel in + if case .reply = panel { self?.islandPanel.makeKey() } + } } // MARK: - FSM wiring @@ -207,6 +214,10 @@ final class IslandWindowController: NSWindowController { } fsm.isHeldOpen = { AppState.shared.pendingApproval != nil } + fsm.isTyping = { + if case .reply = AppState.shared.sidePanel { return AppState.shared.mode == .expanded } + return false + } } // MARK: - 60 Hz polling loop @@ -456,6 +467,9 @@ final class IslandWindowController: NSWindowController { let raw = event.modifierFlags.intersection([.command, .control, .option, .shift]) let cmd = raw == .command + // In the reply panel, ⌘ + arrows move the text cursor + if case .reply = state.sidePanel, cmd, [123, 124, 125, 126].contains(event.keyCode) { return false } + // ⌘→ — next pill if cmd && event.keyCode == 124 { cyclePill(by: +1); return true } // ⌘← — previous pill diff --git a/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift b/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift index b7aa07df20..09f63e0511 100644 --- a/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift +++ b/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift @@ -25,7 +25,6 @@ final class InstructionRunner { private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase } private var pollTask: Task? private var changeToken: CKServerChangeToken? - private var running: [String: Process] = [:] // by session id /// Instructions older than this are dropped instead of run. private let maxAge: TimeInterval = 10 * 60 @@ -93,88 +92,16 @@ final class InstructionRunner { guard Date().timeIntervalSince(createdAt) < maxAge else { log("ignored: older than 10 min"); return } guard !text.isEmpty, text.count <= 8000 else { log("ignored: empty or too long"); return } guard pillId == "integration_claude" || pillId == "agent_cursor" else { log("ignored: \(pillId) can't take instructions"); return } - guard let session = TurnRecorder.shared.lastSession(for: pillId) else { + guard let session = AppState.shared.claudeSessions[pillId] else { log("ignored: no Claude Code session seen for \(pillId) yet") return } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory), isDirectory.boolValue else { - log("ignored: the session's folder is gone") - return - } - guard running[session.sessionId] == nil else { - log("ignored: an instruction is already running for this session") - return - } - guard let claude = Self.claudeExecutable() else { - log("can't find the claude command (looked in ~/.claude/local, Homebrew, /usr/local/bin, ~/.npm-global/bin)") - return - } - run(claude: claude, text: text, sessionId: session.sessionId, cwd: session.cwd, pillId: pillId) - } - - // MARK: Running - - private func run(claude: String, text: String, sessionId: String, cwd: String, pillId: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: claude) - process.arguments = ["-p", text, "--resume", sessionId] - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - var env = ProcessInfo.processInfo.environment - let home = FileManager.default.homeDirectoryForCurrentUser.path - env["PATH"] = [URL(fileURLWithPath: claude).deletingLastPathComponent().path, "/opt/homebrew/bin", "/usr/local/bin", - "/usr/bin", "/bin", "/usr/sbin", "/sbin", "\(home)/.local/bin", env["PATH"] ?? ""].joined(separator: ":") - // The hooks route events by editor: keep them on the same pill. - if pillId == "agent_cursor" { - env["__CFBundleIdentifier"] = "com.todesktop.230313mzl4w4u92" - } else { - env["TERM_PROGRAM"] = "vscode" - } - process.environment = env - process.standardInput = FileHandle.nullDevice - // Output goes to a file (a pipe could fill up and stall claude); its - // end is logged if the run fails. - let logURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Logs/NotchBuddy/instruction-last.log") - try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true) - FileManager.default.createFile(atPath: logURL.path, contents: nil) - let output = try? FileHandle(forWritingTo: logURL) - process.standardOutput = output ?? FileHandle.nullDevice - process.standardError = output ?? FileHandle.nullDevice - process.terminationHandler = { [weak self] finished in - try? output?.close() - let data = (try? Data(contentsOf: logURL)) ?? Data() - let tail = String(decoding: data.suffix(400), as: UTF8.self) - .replacingOccurrences(of: "\n", with: " ") - let status = finished.terminationStatus - Task { @MainActor in - self?.running[sessionId] = nil - self?.log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)") - } - } - do { - try process.run() - running[sessionId] = process - log("running in \(URL(fileURLWithPath: cwd).lastPathComponent) (\(sessionId.prefix(8))): \(text.count) chars") - } catch { - log("couldn't start claude: \(error.localizedDescription)") + if case .failure(let failure) = SessionResumer.shared.resume(session, pillId: pillId, text: text, + log: { [weak self] in self?.log($0) }) { + log("ignored: \(failure)") } } - /// Where `claude` usually lives; the app doesn't get the shell's PATH. - static func claudeExecutable() -> String? { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let candidates = [ - "\(home)/.claude/local/claude", - "\(home)/.local/bin/claude", - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - "\(home)/.npm-global/bin/claude", - "\(home)/.bun/bin/claude", - ] - return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } - } - private func log(_ message: String) { CloudProbe.shared.log("[instruction] \(message)") } diff --git a/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift b/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift index 3aed1ddf7d..025c0a5fdd 100644 --- a/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift +++ b/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift @@ -28,14 +28,6 @@ final class TurnRecorder { private let maxOutput = 1500 private let maxFinal = 12_000 - /// The Claude Code session last seen on a pill and its folder, for - /// instructions sent from the iPhone (InstructionRunner). - func lastSession(for pillId: String) -> (sessionId: String, cwd: String)? { - guard let info = sessions[pillId], !info.sessionId.isEmpty, !info.cwd.isEmpty else { return nil } - return info - } - private var sessions: [String: (sessionId: String, cwd: String)] = [:] - func start() { running = true log("turn recorder on") @@ -60,7 +52,6 @@ final class TurnRecorder { let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? "" let cwd = payload["cwd"] as? String ?? "" let now = Date() - if !sessionId.isEmpty, !cwd.isEmpty { sessions[pillId] = (sessionId, cwd) } switch event { case "UserPromptSubmit": diff --git a/NotchBuddy/Sources/App/SessionResumer.swift b/NotchBuddy/Sources/App/SessionResumer.swift new file mode 100644 index 0000000000..906f76c99a --- /dev/null +++ b/NotchBuddy/Sources/App/SessionResumer.swift @@ -0,0 +1,113 @@ +#if !APPSTORE +import Foundation + +// MARK: - SessionResumer +// +// Continues a Claude Code conversation in the background: +// `claude -p --resume `, in the session's own folder. +// Used by the notch's reply panel and by instructions from the iPhone +// (InstructionRunner). The session and folder come from what this Mac saw in +// the hooks. Hooks keep working, so the notch follows the run like any other +// turn and permission requests still need an explicit click. +// The terminal or editor the session was started in doesn't show this turn. +// GitHub build only: the App Store build is sandboxed and can't start `claude`. + +@MainActor +final class SessionResumer { + static let shared = SessionResumer() + + enum Failure: Error, CustomStringConvertible { + case noSession, folderGone, busy, noClaude, launch(String) + var description: String { + switch self { + case .noSession: return "No Claude Code session seen yet" + case .folderGone: return "The session's folder is gone" + case .busy: return "Already running a reply for this session" + case .noClaude: return "Can't find the claude command" + case .launch(let message): return "Couldn't start claude: \(message)" + } + } + } + + private var running: [String: Process] = [:] // by session id + + func isRunning(_ sessionId: String) -> Bool { running[sessionId] != nil } + + /// Starts the run. `onExit` gets the exit status and the end of the output. + func resume(_ session: ClaudeSessionRef, pillId: String, text: String, + log: @escaping @MainActor (String) -> Void, + onExit: (@MainActor (Int32, String) -> Void)? = nil) -> Result { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory), isDirectory.boolValue else { + return .failure(.folderGone) + } + guard running[session.sessionId] == nil else { return .failure(.busy) } + guard let claude = Self.claudeExecutable() else { return .failure(.noClaude) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: claude) + // "--" ends the options: a message starting with "-" stays a message + process.arguments = ["-p", "--resume", session.sessionId, "--", text] + process.currentDirectoryURL = URL(fileURLWithPath: session.cwd) + var env = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + env["PATH"] = [URL(fileURLWithPath: claude).deletingLastPathComponent().path, "/opt/homebrew/bin", "/usr/local/bin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", "\(home)/.local/bin", env["PATH"] ?? ""].joined(separator: ":") + // The hooks route events by editor or terminal: keep them on the same pill. + if pillId == "agent_cursor" { + env["__CFBundleIdentifier"] = "com.todesktop.230313mzl4w4u92" + } else if let host = session.hostApp { + env["__CFBundleIdentifier"] = host + env["TERM_PROGRAM"] = nil + } else { + env["TERM_PROGRAM"] = "vscode" + } + process.environment = env + process.standardInput = FileHandle.nullDevice + // Output goes to a file (a pipe could fill up and stall claude); its + // end is logged if the run fails. + let logURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/NotchBuddy/instruction-last.log") + try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: logURL.path, contents: nil) + let output = try? FileHandle(forWritingTo: logURL) + process.standardOutput = output ?? FileHandle.nullDevice + process.standardError = output ?? FileHandle.nullDevice + let sessionId = session.sessionId + process.terminationHandler = { [weak self] finished in + try? output?.close() + let data = (try? Data(contentsOf: logURL)) ?? Data() + let tail = String(decoding: data.suffix(400), as: UTF8.self) + .replacingOccurrences(of: "\n", with: " ") + let status = finished.terminationStatus + Task { @MainActor in + self?.running[sessionId] = nil + log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)") + onExit?(status, tail) + } + } + do { + try process.run() + running[sessionId] = process + log("running in \(URL(fileURLWithPath: session.cwd).lastPathComponent) (\(sessionId.prefix(8))): \(text.count) chars") + return .success(()) + } catch { + return .failure(.launch(error.localizedDescription)) + } + } + + /// Where `claude` usually lives; the app doesn't get the shell's PATH. + static func claudeExecutable() -> String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "\(home)/.claude/local/claude", + "\(home)/.local/bin/claude", + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "\(home)/.npm-global/bin/claude", + "\(home)/.bun/bin/claude", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } +} +#endif diff --git a/NotchBuddy/Sources/App/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index 75c62f3c47..453a627d1b 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -49,6 +49,12 @@ struct SidePanelView: View { attention: othersNeedAttention, onClose: close) case .sessionBranch(let taskId): SessionBranchPanel(state: state, taskId: taskId, attention: othersNeedAttention, onClose: close) + case .reply(let taskId): + #if !APPSTORE + SessionReplyPanel(state: state, taskId: taskId, attention: othersNeedAttention, onClose: close) + #else + EmptyView() + #endif case .vercelDeployment(let id): if let dep = state.vercelDeployments.first(where: { $0.id == id }) { VercelDeploymentPanel(deployment: dep, attention: othersNeedAttention, onClose: close) @@ -653,7 +659,7 @@ struct PanelLinkButton: View { // MARK: - Claude Code session → branch -/// The chips after a session's name: its branch and PR. +/// The chips after a session's name: its branch/PR, and the reply button (GitHub build). struct SessionChips: View { @ObservedObject var state: AppState let taskId: String @@ -665,6 +671,13 @@ struct SessionChips: View { state.toggleSidePanel(.sessionBranch(taskId)) } } + #if !APPSTORE + if state.claudeSessions[taskId] != nil { + SessionReplyChip(isOpen: state.sidePanel == .reply(taskId)) { + state.toggleSidePanel(.reply(taskId)) + } + } + #endif } } @@ -855,3 +868,124 @@ struct VercelPreviewDot: View { .help("Vercel \(deployment.target == "production" ? "production" : "preview") · \(deployment.statusLabel) · \(deployment.url)") } } + +// MARK: - Continue a Claude Code session + +#if !APPSTORE +/// Opens the reply panel. Same capsule as SessionBranchChip. +struct SessionReplyChip: View { + let isOpen: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: "arrowshape.turn.up.left.fill") + .font(.system(size: 7.5, weight: .semibold)) + .foregroundColor(Color(hex: isHovered || isOpen ? "#C5C8CD" : "#8E939C")) + .padding(.horizontal, 5) + .frame(height: 15) + .background(Capsule().fill(Color.white.opacity(isHovered || isOpen ? 0.12 : 0.06))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .fixedSize() + .onHover { isHovered = $0 } + .help("Continue this conversation from the notch") + } +} + +/// Sends the next message of a Claude Code session: `claude -p … --resume ` in the +/// background (SessionResumer). The turn then shows in the left card through the hooks. +struct SessionReplyPanel: View { + @ObservedObject var state: AppState + let taskId: String + let attention: Bool + let onClose: () -> Void + @StateObject private var status = PanelStatusModel() + @State private var text = "" + @FocusState private var focused: Bool + + private var task: AgentTask? { state.tasks.first { $0.id == taskId } } + private var session: ClaudeSessionRef? { state.claudeSessions[taskId] } + + /// Why a message can't be sent right now, or nil. + private var blocker: String? { + guard let session else { return "No session seen yet" } + if SessionResumer.shared.isRunning(session.sessionId) { return "Waiting for the reply…" } + switch task?.state { + case .working, .thinking, .searching: return "Busy — wait for this turn to end" + case .approval, .question: return "Waiting for your answer" + default: return nil + } + } + + private func send() { + let message = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty, message.count <= 8000, blocker == nil, let session else { return } + let result = SessionResumer.shared.resume(session, pillId: taskId, text: message, log: { line in + appendAppLog("nb.log", "[reply] \(line)") + }, onExit: { code, tail in + guard code != 0 else { return } + SoundEngine.shared.play("error") + // The panel is closed by now: the reason shows as the session's last line + if let idx = state.tasks.firstIndex(where: { $0.id == taskId }) { + let reason = tail.trimmingCharacters(in: .whitespacesAndNewlines) + state.tasks[idx].finalLine = "⚠ Reply failed" + (reason.isEmpty ? "" : ": " + String(reason.suffix(160))) + } + }) + switch result { + case .success: + text = "" + status.show("Sent", ok: true) + SoundEngine.shared.play("send") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + if state.sidePanel == .reply(taskId) { onClose() } + } + case .failure(let failure): + status.show(failure.description, ok: false) + } + } + + var body: some View { + let hostName = ClaudeHost.name(for: session?.hostApp) + SidePanelChrome(title: "Continue", accent: task?.color ?? "#F5F6F8", + subtitle: task?.name, status: status.current, + attention: attention, onClose: onClose) { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .bottom, spacing: 6) { + TextField(blocker ?? "Message Claude…", text: $text, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1...3) + .focused($focused) + .onSubmit(send) + .disabled(blocker != nil) + Button(action: send) { + Image(systemName: "arrow.up") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(Color(hex: "#0B0C0E")) + .frame(width: 18, height: 18) + .background(Circle().fill(Color(hex: "#F5F6F8"))) + .opacity(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || blocker != nil ? 0.3 : 1) + } + .buttonStyle(.plain) + .disabled(blocker != nil) + .help("Send (Return)") + } + .padding(.horizontal, 9).padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Text("Runs in the background · \(hostName) won't show this turn") + .font(.system(size: 9.5)) + .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1) + .truncationMode(.tail) + } + } + .onAppear { DispatchQueue.main.async { focused = true } } + } +} +#endif From f985a73136f1457ff92c8f2025c3201b056832cf Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 22:29:24 -0600 Subject: [PATCH 4/6] Session header: keep room for the name; repo picker: owner/name on clashes The branch and reply chips squeezed the session name to one letter, so the subtitle (already shown on the pill) is hidden while chips are shown. Watching a fork and its upstream showed two identical "coucou" chips: a repo whose name clashes with another watched repo now shows owner/name. Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/AppState.swift | 7 ++++ .../Sources/App/IslandViewContent.swift | 36 ++++++++++--------- NotchBuddy/Sources/App/SidePanelView.swift | 15 ++++---- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 96d871de85..dc3e8e0f7f 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -564,6 +564,13 @@ final class AppState: ObservableObject { } /// 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 [] } diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 2e00006d3c..9e190be8f4 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -64,17 +64,19 @@ struct OverviewView: View { .lineLimit(1) .truncationMode(.tail) .layoutPriority(1) - Text({ () -> String in - switch agent.source { - case .claudeCode: return "Claude Code" - case .agent: return "Agent" - case .n8n: return "n8n" - } - }()) - .font(.system(size: 11)) - .foregroundColor(Color(hex: "#8E939C")) - .lineLimit(1) - .truncationMode(.tail) + if state.sessionGit[agent.id] == nil { + Text({ () -> String in + switch agent.source { + case .claudeCode: return "Claude Code" + case .agent: return "Agent" + case .n8n: return "n8n" + } + }()) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + .truncationMode(.tail) + } SessionChips(state: state, taskId: agent.id) Spacer(minLength: 2) if agent.steps.count > 1 { @@ -1852,7 +1854,7 @@ struct IntegrationCardView: View { pulse: appState.githubVisiblePulse!, stats: appState.githubStats, activity: appState.githubActivity, - focusLabel: appState.effectiveGithubFocus.map { $0.split(separator: "/").last.map(String.init) ?? $0 }, + focusLabel: appState.effectiveGithubFocus.map(appState.githubRepoLabel), canPickRepo: appState.githubFocusOptions.count > 1 || appState.effectiveGithubFocus != nil, pickerOpen: appState.sidePanel == .githubRepos, onTapTitle: { appState.toggleSidePanel(.githubRepos) }, @@ -1896,10 +1898,12 @@ struct IntegrationCardView: View { .foregroundColor(Color(hex: "#F5F6F8")) .lineLimit(1).truncationMode(.tail) .layoutPriority(1) - Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Agent") - .font(.system(size: 11)) - .foregroundColor(Color(hex: "#8E939C")) - .lineLimit(1).truncationMode(.tail) + if appState.sessionGit[task.id] == nil { + Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Agent") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.tail) + } SessionChips(state: appState, taskId: task.id) Spacer(minLength: 2) if task.steps.count > 1 { diff --git a/NotchBuddy/Sources/App/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index 75c62f3c47..46f14c75fe 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -27,7 +27,7 @@ struct SidePanelView: View { title: "Repositories", accent: "#F4505E", allLabel: "All repos", options: state.githubFocusOptions, selection: state.effectiveGithubFocus, - shortLabel: { $0.split(separator: "/").last.map(String.init) ?? $0 }, + shortLabel: state.githubRepoLabel, attention: othersNeedAttention, onClose: close, onPick: { repo in state.githubFocusRepo = repo @@ -681,17 +681,18 @@ struct SessionBranchChip: View { var body: some View { Button(action: action) { HStack(spacing: 3) { - Circle() - .fill(ghCIDot(ci)) - .frame(width: 5, height: 5) - .opacity(ci == .unknown ? 0.35 : 1) + if ci != .unknown { + Circle() + .fill(ghCIDot(ci)) + .frame(width: 5, height: 5) + } if let pr = info?.pr { Text("#\(pr.number)") } else { Image(systemName: "arrow.branch").font(.system(size: 7, weight: .semibold)) Text(git.branch) .truncationMode(.middle) - .frame(maxWidth: 60, alignment: .leading) + .frame(minWidth: 30, maxWidth: 60, alignment: .leading) } } .font(.system(size: 10, weight: .semibold)) @@ -703,7 +704,7 @@ struct SessionBranchChip: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .fixedSize() + .fixedSize(horizontal: false, vertical: true) // the branch gives way to the session name .onHover { isHovered = $0 } .help(info?.pr.map { "\($0.title) · \(git.repo)" } ?? "\(git.repo) · \(git.branch)") } From 3a633f3ca66fadf94b2047b29a8fe6238c33ec0e Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 22:54:34 -0600 Subject: [PATCH 5/6] Reply: keep the answer on the pill when the run ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `claude -p --resume` run sends Stop and then SessionEnd right away, and SessionEnd removed the task: the finished card showed "VS Code · Session finished" instead of the answer. The session goes on in its terminal or editor, so SessionEnd from a run started here (or ended in the last 5 s) is ignored. Same for iPhone instructions, which use the same runner. Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/HookServer.swift | 5 +++++ NotchBuddy/Sources/App/SessionResumer.swift | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 7994929102..c75dc37f2f 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -562,6 +562,11 @@ final class HookServer: @unchecked Sendable { clearPillBadge(id: agentId) case "SessionEnd": + #if !APPSTORE + // A run started from the notch or the iPhone ends here, but the session + // goes on in its terminal or editor: keep the pill and the run's answer. + if SessionResumer.shared.isOwnRun(sessionId) { break } + #endif activeSessionId = nil if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } state.clearSessionDiffs(for: agentId) diff --git a/NotchBuddy/Sources/App/SessionResumer.swift b/NotchBuddy/Sources/App/SessionResumer.swift index 906f76c99a..919f17d10d 100644 --- a/NotchBuddy/Sources/App/SessionResumer.swift +++ b/NotchBuddy/Sources/App/SessionResumer.swift @@ -30,9 +30,16 @@ final class SessionResumer { } private var running: [String: Process] = [:] // by session id + private var endedAt: [String: Date] = [:] // when each run's process exited func isRunning(_ sessionId: String) -> Bool { running[sessionId] != nil } + /// The event comes from a run started here: running, or ended in the last few + /// seconds (its last hooks can arrive after the process exits). + func isOwnRun(_ sessionId: String) -> Bool { + running[sessionId] != nil || endedAt[sessionId].map { Date().timeIntervalSince($0) < 5 } == true + } + /// Starts the run. `onExit` gets the exit status and the end of the output. func resume(_ session: ClaudeSessionRef, pillId: String, text: String, log: @escaping @MainActor (String) -> Void, @@ -82,6 +89,7 @@ final class SessionResumer { let status = finished.terminationStatus Task { @MainActor in self?.running[sessionId] = nil + self?.endedAt[sessionId] = Date() log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)") onExit?(status, tail) } From 33d719d22aed63f2b0dd4117ab039e9e24521734 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Wed, 7 Oct 2026 14:41:12 -0600 Subject: [PATCH 6/6] Terminal sessions: cmux and Orca, "Open terminal" goes back to the session's app - cmux (com.cmuxterm.app, #54) sets TERM_PROGRAM=ghostty: the bundle id is checked first, so it's no longer taken for Ghostty. Orca (com.stablyai.orca, reported in #32) is recognised too. - The finished card's "Open terminal" brings forward the terminal the session runs in, before the fixed list. A Warp session no longer opens Terminal.app. Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/ClaudeHost.swift | 2 ++ NotchBuddy/Sources/App/IslandViewContent.swift | 5 +++++ tests/ClaudeHostTests.swift | 3 +++ 3 files changed, 10 insertions(+) diff --git a/NotchBuddy/Sources/App/ClaudeHost.swift b/NotchBuddy/Sources/App/ClaudeHost.swift index 7d846d7990..ce4f96afec 100644 --- a/NotchBuddy/Sources/App/ClaudeHost.swift +++ b/NotchBuddy/Sources/App/ClaudeHost.swift @@ -28,6 +28,8 @@ struct ClaudeHost: Equatable { "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", diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index ce9a995de8..d67894cd27 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -566,6 +566,11 @@ struct FinishedView: View { HStack(spacing: 8) { #if !APPSTORE PrimaryButton("Open terminal") { + // The terminal the session runs in, when the hooks told us + if state.focusTask?.id == "integration_claude", ClaudeHost.activate(state.focusTask?.hostApp) { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + return + } let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] let activated = terminalBundleIds.compactMap { id in NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } diff --git a/tests/ClaudeHostTests.swift b/tests/ClaudeHostTests.swift index 5c11b3d7a7..259a538c5f 100644 --- a/tests/ClaudeHostTests.swift +++ b/tests/ClaudeHostTests.swift @@ -17,6 +17,9 @@ enum ClaudeHostTests { check("iTerm", ClaudeHost.terminal(termProgram: "iTerm.app", bundleId: "")?.name == "iTerm") check("tmux inside Ghostty: bundle id wins", ClaudeHost.terminal(termProgram: "tmux", bundleId: "com.mitchellh.ghostty")?.name == "Ghostty") + check("cmux: bundle id wins over TERM_PROGRAM=ghostty", + ClaudeHost.terminal(termProgram: "ghostty", bundleId: "com.cmuxterm.app")?.name == "cmux") + check("Orca by bundle id", ClaudeHost.terminal(termProgram: "", bundleId: "com.stablyai.orca")?.name == "Orca") check("VS Code → nil", ClaudeHost.terminal(termProgram: "vscode", bundleId: "com.microsoft.VSCode") == nil) check("Cursor → nil", ClaudeHost.terminal(termProgram: "vscode", bundleId: "com.todesktop.230313mzl4w4u92") == nil) check("unknown → nil", ClaudeHost.terminal(termProgram: "", bundleId: "com.example.app") == nil)