From a86c793268a631b8d529ae4343c2b20b59cf2e98 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 00:39:01 -0600 Subject: [PATCH 1/6] GitHub and Vercel: watch lists, team scope, notch focus and a right-side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: pick the GitHub repos to watch (up to 20; none = the 10 most recently pushed) and the Vercel scope (personal account or a team). Vercel calls, iPhone actions included, now pass teamId. - Notch: the GitHub / Vercel card title picks the repo or project in focus, as chips in the right card. Alerts still cover every watched item. - Right card: PR lists, default-branch CI and deployment details open in place of the agent pills, which come back on ✕, Esc, a focus change, the island collapsing, or an approval/question. - Vercel list honours the project filter; Vercel stops polling while its pill is off (unless iPhone sync is on); a scope change doesn't alert. - Pulse query moved to GitHubPulse.query(watched:), with tests. - project.pbxproj regenerated with XcodeGen 2.46 (adds SidePanelView.swift). Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 1256 ++++++++++++++++- NotchBuddy/Sources/App/AppState.swift | 105 ++ NotchBuddy/Sources/App/GitHubPulse.swift | 116 +- NotchBuddy/Sources/App/GithubPoller.swift | 48 +- .../Sources/App/IslandViewContent.swift | 233 +-- .../App/PhoneLink/ServiceDetailRunner.swift | 10 +- NotchBuddy/Sources/App/SettingsView.swift | 183 ++- NotchBuddy/Sources/App/SidePanelView.swift | 392 +++++ NotchBuddy/Sources/App/VercelPoller.swift | 46 +- tests/GitHubPulseTests.swift | 45 + 10 files changed, 2193 insertions(+), 241 deletions(-) create mode 100644 NotchBuddy/Sources/App/SidePanelView.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 2d2950eced..e33b7f3ffa 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -8,163 +8,393 @@ /* Begin PBXBuildFile section */ 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */; }; + 0393CA5466F52252378F0346 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; + 0408FE7F86EBEE61CAED32DA /* TurnRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */; }; + 0417DE195518EE21267F8479 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; + 053B30CF4568F7FE5138A898 /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; 065FCE817315454DE3F85F25 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; + 0685C3D47D27D06FCB6E4AD1 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; 06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; }; + 077ECE0906D8E820BD5FA764 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; + 07B49D6825179DAA18DFF4D3 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; + 08A485F2AC63FF5298ED897B /* OpenLeadMochiIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */; }; 091EC3B2F1060D7EE1317E4E /* IslandWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D710BD7834228045869D0184 /* IslandWindowController.swift */; }; 0A267EE2955C7AD8E02A296C /* N8nPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */; }; - 0D68A4C4E3422487DB7B2262 /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */; }; - 0E6E7A12CB5CA4E499DED934 /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C2BC327511DB0B86D4D62E /* MochiOutfitDrawing.swift */; }; + 0D1AD905638B2B021B684A09 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; 0E83C1DD4D20E7FB7A904450 /* VercelPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */; }; - 0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4540F630F8BA04AFFFA3979 /* BotEngine.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 */; }; - 186AB8904029767DAA5277CF /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BC1357785CA1B099A33969 /* IslandScreenGeometry.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 */; }; 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; + 1E55440B97C640993E93D04D /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 1EAFEE80A8066B77F9410025 /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */; }; 1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; }; + 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 */; }; + 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 */; }; + 26E35C79CCD09FEBA04A3EF6 /* LinkTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762C38A8952A90F394EDFE4 /* LinkTestView.swift */; }; 273FC0FA14AAF6CE62FD14AE /* DesktopMochi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */; }; 2875610F701376BD65573386 /* WindowContextCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */; }; + 295F8FE5A9E2E8F1A6389DEE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53E4CAD2BCC77B280689B0F /* HomeView.swift */; }; + 2A30FA2F046492E22A1C5719 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; 2CC2E3B688B15BBEB3AE3BCE /* WindowContextCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */; }; 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */; }; 2F36E3C6C073E968A682AFA4 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; }; 3756B7E1A55D9CEC8C4D34A4 /* IslandStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */; }; 38EEB2D3A6EC538958D92575 /* GitHubPulse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */; }; - 3F19756A396F125EC711247F /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; }; + 40473EEC3029D466B2769672 /* AppleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681E15F99FCF1E60C2553004 /* AppleStyle.swift */; }; 4117E7C8AA231AAE36E7509D /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; + 41966EEF7599793AD70DFCEA /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; + 422C116E73D2D9A96DAE104D /* MochiLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265262A422C270C1798F4A80 /* MochiLive.swift */; }; + 440AAF9FA27CC3DF7CC9EF51 /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; + 4755AAC633DCE5DAA6D54A3C /* ServiceDetailRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */; }; + 4778852A12726803BCF8B357 /* SessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A27178AA6007F7B56901750 /* SessionDetailView.swift */; }; 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; + 4A32FB2EA69E8BA70EBEA8A7 /* ServiceLiveDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21B710512DB99390EC564487 /* ServiceLiveDetail.swift */; }; 4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; + 4B83070258D5AAAC8FCEB15F /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; + 4D34FF10654B19B69FE1964C /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + 4D958F2DDCE9504490FDA357 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; + 52F0DF1FBCB522951910390E /* InstructionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */; }; 530443F46F896F037A56F663 /* HotKeyCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1218B86E249691AF44F819AC /* HotKeyCenter.swift */; }; + 535EC818E1DAF0FA588545D8 /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; + 59FABCC0B6794FE95319825C /* CoucouNotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5B9A00AFB0837731FA678E4E /* IslandStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */; }; 5C6D0BC8C546718CAA82BF90 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F142FB3952B5F0639383047 /* MusicController.swift */; }; + 607E548D1DD274CD73FDC290 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; 60E361FFE84C387E5E3ED1AA /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */; }; 61CB7049490F61CFA1B36B64 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; 61F73B27A7E3AFF584B2CC22 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; }; 62C4EE3F74DAA59764729E62 /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */; }; 63F9F3AAC881D3EDDDD8AED9 /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; + 640D3B3296748445A8F96F6D /* question.wav in Resources */ = {isa = PBXBuildFile; fileRef = A5DC0954D23B69B247578437 /* question.wav */; }; 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; }; - 6697436AA05A3F92C1DD85C3 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB24C2469551040FBAA4DBF /* DiffEngine.swift */; }; + 654A8E7D8BA79AF90FA8B0DA /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; + 658C579523CF5F8D67B31369 /* InstructionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */; }; + 65CB53E20B2289CA3AC3E8FE /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; + 68C3ACC708B69035FD0CB8C4 /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + 694AB4504CD65662E1681E5C /* ServicePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */; }; + 6A1AC92C431FA842928E8115 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB831F5623E699A2506698E /* NotificationViewController.swift */; }; + 6B4A9D635FD529CE1C2692B8 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; 6CCEC73ED68E9F253C22D692 /* ShortcutLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA51E5B140C20812641168EA /* ShortcutLogic.swift */; }; 6D158183870434FEDF07E050 /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; }; + 6D71A1FA10C766E465F326D4 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; + 6DDBB5EDE6BEFD4225238C28 /* approval.wav in Resources */ = {isa = PBXBuildFile; fileRef = 11FCBBE20532B1F0A184EA06 /* approval.wav */; }; 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; + 71CBD1D39A6668BF454CA1C5 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; + 724E602A05FE5A6D58218E27 /* CoucouControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C406C508A86E215B3CBE3FC /* CoucouControl.swift */; }; + 725365FA4E184D970E7BB8B4 /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; + 72B1AEDB2152BF73659EB598 /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; + 73BD92A06DA7C72FFF26DD0F /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; + 74E97082F2241C36E5DD22EE /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + 770F21C6A4D840AD3B0A9CB9 /* MochiLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265262A422C270C1798F4A80 /* MochiLive.swift */; }; 78B3C6814431B2DD9D1F4949 /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */; }; + 78F045A55E6F4EEA5787EEEF /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; 78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; }; 7C75CCB3DC0116D8D3B12313 /* AskQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */; }; + 7CF3242DAD44B442297116E5 /* Decisions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369F5AFDE37A03A1C25EB262 /* Decisions.swift */; }; + 7D50086C2D8B6AEA0BD138FC /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; + 81250AE3E83493CF806D459B /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; + 8173F93555ED400954AA1CB3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097F57179CC066459E64303A /* AboutView.swift */; }; + 82E72E0AFCAB192D98AB2004 /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.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, ); }; }; + 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 */; }; 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 */; }; + 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 */; }; + 8E9A18333EE821A3CF9F7E34 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; }; 908043F62E2C31F5FBC0A0C4 /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */; }; - 94A742758D0FF79124E26B21 /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0BB15BD82E4A0B729045880 /* MochiWardrobe.swift */; }; + 920EC34AE78BDC6DDD4452F9 /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + 924CE1172B5572DA3E45A7C9 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; + 9321A0D4BEC32E262D4A85ED /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; + 9377B2B9A1B44E0113DBF619 /* TurnArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5F9E4BB282FDE68D65FE6B /* TurnArchive.swift */; }; + 93AB64BF47D6D1EE7DB4C22B /* QuestionRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */; }; + 93CD58567884F5B7715D3A3E /* QuestionCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8296E14A0C15515E6C3C1DBB /* QuestionCard.swift */; }; + 9492F8E414E8E1814F01EE66 /* ApprovalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BEAB5F20A13E5806A42EC9 /* ApprovalCard.swift */; }; 94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */; }; 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; + 9728564FCBF0334E68E9E6E7 /* SessionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */; }; + 99A1CF41C797EA7F98F2B5B1 /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + 9A04B16AEEAB1A344D13CE74 /* InstructionComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E74B3586BC0861C69E70A /* InstructionComposer.swift */; }; + 9AEFD05D0A39D4AF3C091B3A /* QuestionRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */; }; + 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 */; }; + 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 */; }; + A885B7E6DC43231BECE36E3D /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.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 */; }; + B2584220B877056366C8DCAE /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; }; + B314F09AD7924387D0E04B45 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; B3EC9CF3C8BD629C3530E8F1 /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; + B5D8D34DD7A72F340030EF46 /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; B733A76DD197F6D6D949284C /* HotKeyCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1218B86E249691AF44F819AC /* HotKeyCenter.swift */; }; + B7C93262AA9D5DEAA0B79C7C /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; + B7ED198C941104866BB3BC57 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B99CF65C5E4BC887888C6 /* Haptics.swift */; }; BA65FC4A40F7227837C26214 /* DesktopMochiLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */; }; + BC6819C32C715E203254B9C6 /* TurnShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E156CF1C215B8FE713B3A0 /* TurnShare.swift */; }; + BCB4B7AEA1140868C36F39CC /* SessionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F622EF0C553F053716C2 /* SessionStatus.swift */; }; + BDD8A210DD6D9DA2D15BC5EE /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; BEC5DDB55B3DA67C61CA8470 /* DesktopMochi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */; }; C07E9A04E827284FDF33DB16 /* ShortcutLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA51E5B140C20812641168EA /* ShortcutLogic.swift */; }; C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */; }; - C1A29371181553FB4169E195 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB24C2469551040FBAA4DBF /* DiffEngine.swift */; }; - C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4540F630F8BA04AFFFA3979 /* BotEngine.swift */; }; + C209B71CDC296F561F10A443 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; + C3D39D96E087E98F8BF59226 /* LastTurnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112D2BD70628CD67ACDD18AF /* LastTurnView.swift */; }; + C3ECA730677F467D28910AF6 /* LiveActivityRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */; }; + C5549C2D60998E297A7DC593 /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + C5737FE7AE6B2BA92B9A8F72 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; C61DDE2EC5FDDB39E4AA3B34 /* IslandWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D710BD7834228045869D0184 /* IslandWindowController.swift */; }; + C67B690DFC0A294C0E65FCBB /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */; }; C93BA975D35B8CD7473CF995 /* DesktopMochiLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */; }; C940B651C6CA952D99388E04 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; }; + C9ED8FFDC406DD1A23AABC7C /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; CA3321C14A11FED94E48B3E3 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; + CAC1A12153A798730E5FA835 /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; + CC81DAE27417672CB93349A5 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; + CD05A497A7D0B3D723D92C06 /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; }; - D58B1E93AF65DE404C40E21E /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */; }; - D82E93014C8087AE81511E40 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; }; + CDD36B01BF50F64A2A3180EF /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; + CE0A1BD64DF3E08D200691A6 /* TurnRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */; }; + CE5EBAEC47402D4987EA86C7 /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; + D092D0D597D1CBCE6B775B5E /* ServicePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */; }; + D19FA3394EE99B34184684ED /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; + D20B2B175A5C1D9B410B51C0 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; + D2746E6D92F2E4C46892D5AA /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; + D34D31C27D97D23C0441D1BE /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + D45D9C8EAF67FC2F46E9F352 /* CloudProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28323DC20034A281F6829884 /* CloudProbe.swift */; }; + D6559770BBFB06FD4E90187B /* OpenLeadMochiIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */; }; D861479F6E7EAD07A6AC2105 /* ClaudePlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */; }; D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */; }; + DBA38F8F673F436CE1D4A2E0 /* MochiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */; }; DCAC390FF9EC65B945A15B24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; }; + DD2EEF113DB5EAA35CD88312 /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; }; + DE66BB6D18F467A7F4220CC3 /* SessionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */; }; + E1581F8F2725B26E90D91C4A /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2EED5205DD7161E51BE1E1 /* IntroView.swift */; }; E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; + E701BC8996229D08362CA732 /* ServiceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF705275522F99CAE7E1429 /* ServiceStatus.swift */; }; E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; - E7A5D23F455565FE1E210398 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */; }; E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; }; - E85D8AC2617F7742C2547E11 /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C2BC327511DB0B86D4D62E /* MochiOutfitDrawing.swift */; }; + 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 */; }; ED5D9A334DE08C0EB81DE322 /* AskQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */; }; + ED62D1B9E6A35B1F85074A2A /* SharedSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1064AD8471C51AFF4F12258 /* SharedSessions.swift */; }; + EE3AB10544113500486A9684 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; + EF9E6418C3F39F6FE88FD812 /* CoucouPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9CEB5655FCA197D6B10A91 /* CoucouPhoneApp.swift */; }; + EFAA9936D5C9DE635CFBA2AB /* SharedSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1064AD8471C51AFF4F12258 /* SharedSessions.swift */; }; F06EE0A97CB77096A337AA8B /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; - F11A2EBCBF7352536B2CB93D /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0BB15BD82E4A0B729045880 /* MochiWardrobe.swift */; }; + F1EE04000665E204E02DC5AC /* DenyApprovalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */; }; + F25EB7EF3662AFF99AA1C8A1 /* TeamConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C1BB0DF7770A6B263995E6 /* TeamConfiguration.swift */; }; + 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 */; }; + 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 */; }; FBBC4CDE82B5886A83A27D7A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; }; + FDF87CF7C5F703EDD9A7CC14 /* CloudProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28323DC20034A281F6829884 /* CloudProbe.swift */; }; FEFEF1EBAB0E6CA74A33C10B /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 3AA30D1108F3B5AEB81745D9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ACF315E0C1F7FECADF1B4FBC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 65F75F0262AF9C56FEF86BF4; + remoteInfo = CoucouNotificationContent; + }; + 488F3E8D639E084CD826D83C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ACF315E0C1F7FECADF1B4FBC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0387C996BACD956C91E19390; + remoteInfo = CoucouWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 2BD957E90AF4617AAE973348 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 868E3EB4A275AA2394EA20BF /* CoucouWidgets.appex in Embed Foundation Extensions */, + 59FABCC0B6794FE95319825C /* CoucouNotificationContent.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 011D05714C97CF5CAB32DCE7 /* AgentNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotifier.swift; sourceTree = ""; }; + 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; + 045E7300878138F0AC385920 /* MochiStill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiStill.swift; sourceTree = ""; }; + 08128342A3F894D567AD9E6B /* ServiceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceViews.swift; sourceTree = ""; }; + 097F57179CC066459E64303A /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 0D9CEB5655FCA197D6B10A91 /* CoucouPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouPhoneApp.swift; sourceTree = ""; }; + 0FA2D136C81C6D930C5357BD /* LiveActivityLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityLink.swift; sourceTree = ""; }; + 112D2BD70628CD67ACDD18AF /* LastTurnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTurnView.swift; sourceTree = ""; }; + 11FCBBE20532B1F0A184EA06 /* approval.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = approval.wav; sourceTree = ""; }; 1218B86E249691AF44F819AC /* HotKeyCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyCenter.swift; sourceTree = ""; }; + 12E32ED3910DED20BEEF39E5 /* CoucouPhone.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CoucouPhone.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRelay.swift; sourceTree = ""; }; 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandRootView.swift; sourceTree = ""; }; 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubActivity.swift; sourceTree = ""; }; 192844E7299DEED5C2372067 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 1B2EED5205DD7161E51BE1E1 /* IntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroView.swift; sourceTree = ""; }; 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEngine.swift; sourceTree = ""; }; + 21B710512DB99390EC564487 /* ServiceLiveDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLiveDetail.swift; sourceTree = ""; }; + 265262A422C270C1798F4A80 /* MochiLive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiLive.swift; sourceTree = ""; }; + 28323DC20034A281F6829884 /* CloudProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudProbe.swift; sourceTree = ""; }; + 29E156CF1C215B8FE713B3A0 /* TurnShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnShare.swift; sourceTree = ""; }; + 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnSnapshot.swift; sourceTree = ""; }; 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripePoller.swift; sourceTree = ""; }; 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcomPoller.swift; sourceTree = ""; }; 3232F258523DD8D09B670363 /* FileDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropView.swift; sourceTree = ""; }; - 34C2BC327511DB0B86D4D62E /* MochiOutfitDrawing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiOutfitDrawing.swift; sourceTree = ""; }; - 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillCatalog.swift; sourceTree = ""; }; - 3DB24C2469551040FBAA4DBF /* DiffEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffEngine.swift; sourceTree = ""; }; + 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiActivityAttributes.swift; sourceTree = ""; }; + 369F5AFDE37A03A1C25EB262 /* Decisions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Decisions.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; 506824380E9C80B9BC6DCE72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5108F622EF0C553F053716C2 /* SessionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStatus.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 = ""; }; + 66E6F5FC81429871B6F766A8 /* BotEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotEngine.swift; sourceTree = ""; }; + 6762C38A8952A90F394EDFE4 /* LinkTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTestView.swift; sourceTree = ""; }; + 681E15F99FCF1E60C2553004 /* AppleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleStyle.swift; sourceTree = ""; }; + 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionPayload.swift; sourceTree = ""; }; + 6E5F9E4BB282FDE68D65FE6B /* TurnArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnArchive.swift; sourceTree = ""; }; + 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePublisher.swift; sourceTree = ""; }; 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadSequenceEngine.swift; sourceTree = ""; }; + 708E1FD9894461B4AD727DB1 /* CoucouIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouIntents.swift; sourceTree = ""; }; 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VercelPoller.swift; sourceTree = ""; }; - 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 871CA2BADC67D97C97C7009D /* error.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = error.wav; sourceTree = ""; }; 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; + 8ACB4350729C6513824D1ADF /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHex.swift; sourceTree = ""; }; 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanGauge.swift; sourceTree = ""; }; 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = ""; }; - 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; + 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; + 96C64C4C5D705CE486221B74 /* PillCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillCatalog.swift; sourceTree = ""; }; + 9AF705275522F99CAE7E1429 /* ServiceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceStatus.swift; sourceTree = ""; }; + 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacStubs.swift; sourceTree = ""; }; 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; A14A61070D293AB4BBA303F7 /* NotchBuddy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NotchBuddy.app; sourceTree = BUILT_PRODUCTS_DIR; }; A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotCanvasView.swift; sourceTree = ""; }; + A5DC0954D23B69B247578437 /* question.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = question.wav; sourceTree = ""; }; A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; + AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionRelay.swift; sourceTree = ""; }; + ADA6A002ABEC0CC57667FBE0 /* MochiLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiLiveActivity.swift; sourceTree = ""; }; B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopMochi.swift; sourceTree = ""; }; B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCanvasView.swift; sourceTree = ""; }; B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeWebURL.swift; sourceTree = ""; }; + B32FB972D02162720E566674 /* DiffEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffEngine.swift; sourceTree = ""; }; + B53E4CAD2BCC77B280689B0F /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + B6723392BE01AF210E484316 /* CoucouWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouWidgets.swift; sourceTree = ""; }; B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = N8nPoller.swift; sourceTree = ""; }; BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotionPoller.swift; sourceTree = ""; }; BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdownView.swift; sourceTree = ""; }; C066E2B991AA4F56B2724263 /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; name = sounds; path = Resources/sounds; sourceTree = SOURCE_ROOT; }; C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopMochiLogic.swift; sourceTree = ""; }; - C0BB15BD82E4A0B729045880 /* MochiWardrobe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiWardrobe.swift; sourceTree = ""; }; + C22AF9F301D7A23DA6254D49 /* PhoneLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneLink.swift; sourceTree = ""; }; + 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 = ""; }; - D4540F630F8BA04AFFFA3979 /* BotEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotEngine.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 = ""; }; D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdown.swift; sourceTree = ""; }; D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandStateMachine.swift; sourceTree = ""; }; + D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DenyApprovalIntent.swift; sourceTree = ""; }; + D8C1BB0DF7770A6B263995E6 /* TeamConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamConfiguration.swift; sourceTree = ""; }; + DD426E81DF79CA79888059D9 /* ApprovalRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalRelay.swift; sourceTree = ""; }; + DDB831F5623E699A2506698E /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; + DE18399D1CD69468CCF57966 /* MochiActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiActivityState.swift; sourceTree = ""; }; + E18E74B3586BC0861C69E70A /* InstructionComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionComposer.swift; sourceTree = ""; }; + E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDetailRunner.swift; sourceTree = ""; }; + E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiOutfitDrawing.swift; sourceTree = ""; }; E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = ""; }; EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskQuestion.swift; sourceTree = ""; }; + EE60A834934474CDAE022FF8 /* SearchAndSpotlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndSpotlight.swift; sourceTree = ""; }; + F1064AD8471C51AFF4F12258 /* SharedSessions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedSessions.swift; sourceTree = ""; }; F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBuddyApp.swift; sourceTree = ""; }; F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanCardView.swift; sourceTree = ""; }; F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentity.swift; sourceTree = ""; }; + F86B99CF65C5E4BC887888C6 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubPulse.swift; sourceTree = ""; }; FA02B08886939FF6FE0475DD /* IslandViewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandViewContent.swift; sourceTree = ""; }; FA51E5B140C20812641168EA /* ShortcutLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutLogic.swift; sourceTree = ""; }; + FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionRunner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + 061D81F5DBAB8DB3ADFD0CB8 /* sounds */ = { + isa = PBXGroup; + children = ( + 11FCBBE20532B1F0A184EA06 /* approval.wav */, + 871CA2BADC67D97C97C7009D /* error.wav */, + 37147F42613183DBA8896835 /* finish.wav */, + A5DC0954D23B69B247578437 /* question.wav */, + ); + path = sounds; + sourceTree = ""; + }; 2D4550630644796DA0649B81 /* App */ = { isa = PBXGroup; children = ( @@ -173,7 +403,6 @@ C2E4F18AA940EFF7E047B9F7 /* AppState.swift */, EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */, A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */, - D4540F630F8BA04AFFFA3979 /* BotEngine.swift */, 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, @@ -182,7 +411,6 @@ A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */, C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */, - 3DB24C2469551040FBAA4DBF /* DiffEngine.swift */, 3232F258523DD8D09B670363 /* FileDropView.swift */, 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, @@ -191,24 +419,20 @@ 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */, 1218B86E249691AF44F819AC /* HotKeyCenter.swift */, 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */, - 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */, D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */, - 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */, FA02B08886939FF6FE0475DD /* IslandViewContent.swift */, D710BD7834228045869D0184 /* IslandWindowController.swift */, 8790ACE9F73BD74640A0F20C /* LocalChat.swift */, - 34C2BC327511DB0B86D4D62E /* MochiOutfitDrawing.swift */, - C0BB15BD82E4A0B729045880 /* MochiWardrobe.swift */, 4F142FB3952B5F0639383047 /* MusicController.swift */, B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */, F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */, BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */, - 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */, 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */, B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, + 60F31F7C3961439BAB9A8CF1 /* SidePanelView.swift */, 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */, 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */, B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */, @@ -216,23 +440,66 @@ F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */, 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */, E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */, + 9207F9B32D9C7844F5332A31 /* PhoneLink */, ); path = App; sourceTree = ""; }; + 53BB44AD4AC9AB9A0B95AB79 /* Widgets */ = { + isa = PBXGroup; + children = ( + 5C406C508A86E215B3CBE3FC /* CoucouControl.swift */, + B6723392BE01AF210E484316 /* CoucouWidgets.swift */, + ADA6A002ABEC0CC57667FBE0 /* MochiLiveActivity.swift */, + D8C1BB0DF7770A6B263995E6 /* TeamConfiguration.swift */, + ); + path = Widgets; + sourceTree = ""; + }; 6CF11AAA7898712CF6DB364A /* Products */ = { isa = PBXGroup; children = ( 4DC2A07E42245A8CF3BBD2AB /* CoucouAppStore.app */, + D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */, + 12E32ED3910DED20BEEF39E5 /* CoucouPhone.app */, + 5C3E2269A202B640E7F3B5E0 /* CoucouWidgets.appex */, A14A61070D293AB4BBA303F7 /* NotchBuddy.app */, ); name = Products; sourceTree = ""; }; + 9207F9B32D9C7844F5332A31 /* PhoneLink */ = { + isa = PBXGroup; + children = ( + DD426E81DF79CA79888059D9 /* ApprovalRelay.swift */, + 28323DC20034A281F6829884 /* CloudProbe.swift */, + FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */, + 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */, + AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */, + E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */, + 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */, + 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */, + C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */, + ); + path = PhoneLink; + sourceTree = ""; + }; + AEBA361B10A737C684E95B82 /* NotificationContent */ = { + isa = PBXGroup; + children = ( + DDB831F5623E699A2506698E /* NotificationViewController.swift */, + ); + path = NotificationContent; + sourceTree = ""; + }; B382DF6D17081F63DA1CC08B /* Sources */ = { isa = PBXGroup; children = ( 2D4550630644796DA0649B81 /* App */, + F70998159AAE45617BAE86A7 /* CoucouKit */, + AEBA361B10A737C684E95B82 /* NotificationContent */, + E52447A6A81E4EEFC9AF5D4E /* Phone */, + 53BB44AD4AC9AB9A0B95AB79 /* Widgets */, ); path = Sources; sourceTree = ""; @@ -241,6 +508,7 @@ isa = PBXGroup; children = ( 506824380E9C80B9BC6DCE72 /* Assets.xcassets */, + 60401D1023D17C3FD30D7113 /* PhoneAssets.xcassets */, D3593DF62D69E62DA5121807 /* Resources */, B382DF6D17081F63DA1CC08B /* Sources */, 6CF11AAA7898712CF6DB364A /* Products */, @@ -251,13 +519,90 @@ isa = PBXGroup; children = ( C066E2B991AA4F56B2724263 /* sounds */, + 061D81F5DBAB8DB3ADFD0CB8 /* sounds */, ); path = Resources; sourceTree = ""; }; + E52447A6A81E4EEFC9AF5D4E /* Phone */ = { + isa = PBXGroup; + children = ( + 097F57179CC066459E64303A /* AboutView.swift */, + 011D05714C97CF5CAB32DCE7 /* AgentNotifier.swift */, + 681E15F99FCF1E60C2553004 /* AppleStyle.swift */, + 38BEAB5F20A13E5806A42EC9 /* ApprovalCard.swift */, + 708E1FD9894461B4AD727DB1 /* CoucouIntents.swift */, + 0D9CEB5655FCA197D6B10A91 /* CoucouPhoneApp.swift */, + 369F5AFDE37A03A1C25EB262 /* Decisions.swift */, + D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */, + F86B99CF65C5E4BC887888C6 /* Haptics.swift */, + B53E4CAD2BCC77B280689B0F /* HomeView.swift */, + E18E74B3586BC0861C69E70A /* InstructionComposer.swift */, + 1B2EED5205DD7161E51BE1E1 /* IntroView.swift */, + 49E39852175E81C9632A59C9 /* KitPreviewView.swift */, + 112D2BD70628CD67ACDD18AF /* LastTurnView.swift */, + 6762C38A8952A90F394EDFE4 /* LinkTestView.swift */, + 0FA2D136C81C6D930C5357BD /* LiveActivityLink.swift */, + 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */, + 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */, + 265262A422C270C1798F4A80 /* MochiLive.swift */, + D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */, + C22AF9F301D7A23DA6254D49 /* PhoneLink.swift */, + 8296E14A0C15515E6C3C1DBB /* QuestionCard.swift */, + EE60A834934474CDAE022FF8 /* SearchAndSpotlight.swift */, + 21B710512DB99390EC564487 /* ServiceLiveDetail.swift */, + 9AF705275522F99CAE7E1429 /* ServiceStatus.swift */, + 08128342A3F894D567AD9E6B /* ServiceViews.swift */, + 3A27178AA6007F7B56901750 /* SessionDetailView.swift */, + 5108F622EF0C553F053716C2 /* SessionStatus.swift */, + F1064AD8471C51AFF4F12258 /* SharedSessions.swift */, + 6E5F9E4BB282FDE68D65FE6B /* TurnArchive.swift */, + 29E156CF1C215B8FE713B3A0 /* TurnShare.swift */, + ); + path = Phone; + sourceTree = ""; + }; + F70998159AAE45617BAE86A7 /* CoucouKit */ = { + isa = PBXGroup; + children = ( + 66E6F5FC81429871B6F766A8 /* BotEngine.swift */, + 8ACB4350729C6513824D1ADF /* ColorHex.swift */, + B32FB972D02162720E566674 /* DiffEngine.swift */, + 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */, + 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */, + DE18399D1CD69468CCF57966 /* MochiActivityState.swift */, + E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */, + 045E7300878138F0AC385920 /* MochiStill.swift */, + 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */, + 96C64C4C5D705CE486221B74 /* PillCatalog.swift */, + 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */, + 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */, + 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */, + 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */, + ); + path = CoucouKit; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 0387C996BACD956C91E19390 /* CoucouWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = E24C031E9F3A787E693F616B /* Build configuration list for PBXNativeTarget "CoucouWidgets" */; + buildPhases = ( + C69BB9E40CB3FDC60B8EF4C8 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CoucouWidgets; + packageProductDependencies = ( + ); + productName = CoucouWidgets; + productReference = 5C3E2269A202B640E7F3B5E0 /* CoucouWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; 12E59C1D41BFA3D2F7CDD7F5 /* NotchBuddy */ = { isa = PBXNativeTarget; buildConfigurationList = DFC46BE61950064F9ADCEFC3 /* Build configuration list for PBXNativeTarget "NotchBuddy" */; @@ -294,6 +639,44 @@ productReference = 4DC2A07E42245A8CF3BBD2AB /* CoucouAppStore.app */; productType = "com.apple.product-type.application"; }; + 65F75F0262AF9C56FEF86BF4 /* CoucouNotificationContent */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8FE04C40E517AF7D8F28493 /* Build configuration list for PBXNativeTarget "CoucouNotificationContent" */; + buildPhases = ( + 1A0F9A73C6B7D5D95C868C56 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CoucouNotificationContent; + packageProductDependencies = ( + ); + productName = CoucouNotificationContent; + productReference = D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 73F29874BE2F4DF201E7AE81 /* CoucouPhone */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA7C004DA23898CA63062F23 /* Build configuration list for PBXNativeTarget "CoucouPhone" */; + buildPhases = ( + 61F50315F8B3F360A125F7D9 /* Sources */, + E827F537C3FDC8925E13EEE2 /* Resources */, + 2BD957E90AF4617AAE973348 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D710F40BCF9AB164F9ED46E0 /* PBXTargetDependency */, + 9AB649342898423F425FB7B7 /* PBXTargetDependency */, + ); + name = CoucouPhone; + packageProductDependencies = ( + ); + productName = CoucouPhone; + productReference = 12E32ED3910DED20BEEF39E5 /* CoucouPhone.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -303,13 +686,22 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2700; TargetAttributes = { - 12E59C1D41BFA3D2F7CDD7F5 = { - ProvisioningStyle = Manual; + 0387C996BACD956C91E19390 = { + DevelopmentTeam = 256AUJ9555; + ProvisioningStyle = Automatic; }; 495922874E20462B9FC47E53 = { DevelopmentTeam = 256AUJ9555; ProvisioningStyle = Automatic; }; + 65F75F0262AF9C56FEF86BF4 = { + DevelopmentTeam = 256AUJ9555; + ProvisioningStyle = Automatic; + }; + 73F29874BE2F4DF201E7AE81 = { + DevelopmentTeam = 256AUJ9555; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 2B80F5D9D7D10C755DEF1E61 /* Build configuration list for PBXProject "NotchBuddy" */; @@ -328,6 +720,9 @@ targets = ( 12E59C1D41BFA3D2F7CDD7F5 /* NotchBuddy */, 495922874E20462B9FC47E53 /* CoucouAppStore */, + 73F29874BE2F4DF201E7AE81 /* CoucouPhone */, + 0387C996BACD956C91E19390 /* CoucouWidgets */, + 65F75F0262AF9C56FEF86BF4 /* CoucouNotificationContent */, ); }; /* End PBXProject section */ @@ -351,6 +746,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E827F537C3FDC8925E13EEE2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F6CA56F362902839EF9A3E2E /* PhoneAssets.xcassets in Resources */, + 6DDBB5EDE6BEFD4225238C28 /* approval.wav in Resources */, + A7EEF05A08F6B95370712089 /* error.wav in Resources */, + 134D136F3CF0B91AEBCA84EB /* finish.wav in Resources */, + 640D3B3296748445A8F96F6D /* question.wav in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -361,18 +768,21 @@ C940B651C6CA952D99388E04 /* AppDelegate.swift in Sources */, 4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */, 1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */, + 23BFB915C12B0BC4E9B50AAA /* ApprovalRelay.swift in Sources */, 7C75CCB3DC0116D8D3B12313 /* AskQuestion.swift in Sources */, CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */, - C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */, + 845D762E9F4F0AD0D5FCEDF4 /* BotEngine.swift in Sources */, 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */, 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */, 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */, 94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */, D861479F6E7EAD07A6AC2105 /* ClaudePlanGauge.swift in Sources */, 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */, + D45D9C8EAF67FC2F46E9F352 /* CloudProbe.swift in Sources */, + EE3AB10544113500486A9684 /* ColorHex.swift in Sources */, BEC5DDB55B3DA67C61CA8470 /* DesktopMochi.swift in Sources */, BA65FC4A40F7227837C26214 /* DesktopMochiLogic.swift in Sources */, - 6697436AA05A3F92C1DD85C3 /* DiffEngine.swift in Sources */, + 077ECE0906D8E820BD5FA764 /* DiffEngine.swift in Sources */, E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */, D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */, 862CE6CB220D55E46F646DA9 /* GitHubPulse.swift in Sources */, @@ -380,27 +790,41 @@ 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */, 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */, B733A76DD197F6D6D949284C /* HotKeyCenter.swift in Sources */, + 658C579523CF5F8D67B31369 /* InstructionRunner.swift in Sources */, CA3321C14A11FED94E48B3E3 /* IslandRootView.swift in Sources */, - 186AB8904029767DAA5277CF /* IslandScreenGeometry.swift in Sources */, + C67B690DFC0A294C0E65FCBB /* IslandScreenGeometry.swift in Sources */, 5B9A00AFB0837731FA678E4E /* IslandStateMachine.swift in Sources */, - 3F19756A396F125EC711247F /* IslandTypes.swift in Sources */, + A7733EE60694EA3E2A86C37A /* IslandTypes.swift in Sources */, 065FCE817315454DE3F85F25 /* IslandViewContent.swift in Sources */, 091EC3B2F1060D7EE1317E4E /* IslandWindowController.swift in Sources */, + C3ECA730677F467D28910AF6 /* LiveActivityRelay.swift in Sources */, 6D158183870434FEDF07E050 /* LocalChat.swift in Sources */, - E85D8AC2617F7742C2547E11 /* MochiOutfitDrawing.swift in Sources */, - 94A742758D0FF79124E26B21 /* MochiWardrobe.swift in Sources */, + D34D31C27D97D23C0441D1BE /* MochiActivityState.swift in Sources */, + DD2EEF113DB5EAA35CD88312 /* MochiOutfitDrawing.swift in Sources */, + 72B1AEDB2152BF73659EB598 /* MochiStill.swift in Sources */, + 25F66E99460D62478899A292 /* MochiWardrobe.swift in Sources */, 5C6D0BC8C546718CAA82BF90 /* MusicController.swift in Sources */, 0A267EE2955C7AD8E02A296C /* N8nPoller.swift in Sources */, 06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */, 2F36E3C6C073E968A682AFA4 /* NotionPoller.swift in Sources */, - D58B1E93AF65DE404C40E21E /* PillCatalog.swift in Sources */, + BDD8A210DD6D9DA2D15BC5EE /* PillCatalog.swift in Sources */, + CD05A497A7D0B3D723D92C06 /* QuestionPayload.swift in Sources */, + 93AB64BF47D6D1EE7DB4C22B /* QuestionRelay.swift in Sources */, 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */, 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */, + 2A30FA2F046492E22A1C5719 /* ServiceDetail.swift in Sources */, + 9F43BE6E77E371FCEB7FE8BA /* ServiceDetailRunner.swift in Sources */, + 694AB4504CD65662E1681E5C /* ServicePublisher.swift in Sources */, + 0393CA5466F52252378F0346 /* ServiceSnapshot.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 */, + 68C3ACC708B69035FD0CB8C4 /* TurnSnapshot.swift in Sources */, 62C4EE3F74DAA59764729E62 /* UploadCanvasView.swift in Sources */, FAD78D74C6906D4EC8AA4101 /* UploadSequenceEngine.swift in Sources */, 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */, @@ -409,6 +833,82 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1A0F9A73C6B7D5D95C868C56 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6D71A1FA10C766E465F326D4 /* BotEngine.swift in Sources */, + 6B4A9D635FD529CE1C2692B8 /* ColorHex.swift in Sources */, + 8E9A18333EE821A3CF9F7E34 /* DiffEngine.swift in Sources */, + 73BD92A06DA7C72FFF26DD0F /* IslandScreenGeometry.swift in Sources */, + 17D6063D3B374FB82340314E /* IslandTypes.swift in Sources */, + B5D8D34DD7A72F340030EF46 /* MacStubs.swift in Sources */, + 920EC34AE78BDC6DDD4452F9 /* MochiActivityState.swift in Sources */, + 770F21C6A4D840AD3B0A9CB9 /* MochiLive.swift in Sources */, + 4B83070258D5AAAC8FCEB15F /* MochiOutfitDrawing.swift in Sources */, + E82CA1269BF4859EEDE322AE /* MochiStill.swift in Sources */, + B2584220B877056366C8DCAE /* MochiWardrobe.swift in Sources */, + 6A1AC92C431FA842928E8115 /* NotificationViewController.swift in Sources */, + D20B2B175A5C1D9B410B51C0 /* PillCatalog.swift in Sources */, + 7D50086C2D8B6AEA0BD138FC /* QuestionPayload.swift in Sources */, + C5737FE7AE6B2BA92B9A8F72 /* ServiceDetail.swift in Sources */, + C209B71CDC296F561F10A443 /* ServiceSnapshot.swift in Sources */, + C5549C2D60998E297A7DC593 /* TurnSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 61F50315F8B3F360A125F7D9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8173F93555ED400954AA1CB3 /* AboutView.swift in Sources */, + 0FAAA8683282E9E8EA9E2416 /* AgentNotifier.swift in Sources */, + 40473EEC3029D466B2769672 /* AppleStyle.swift in Sources */, + 9492F8E414E8E1814F01EE66 /* ApprovalCard.swift in Sources */, + ED484F8DAC497C77F3E81F0E /* BotEngine.swift in Sources */, + 607E548D1DD274CD73FDC290 /* ColorHex.swift in Sources */, + 8AC19BAF88822CB31519D447 /* CoucouIntents.swift in Sources */, + EF9E6418C3F39F6FE88FD812 /* CoucouPhoneApp.swift in Sources */, + 7CF3242DAD44B442297116E5 /* Decisions.swift in Sources */, + F1EE04000665E204E02DC5AC /* DenyApprovalIntent.swift in Sources */, + 41966EEF7599793AD70DFCEA /* DiffEngine.swift in Sources */, + B7ED198C941104866BB3BC57 /* Haptics.swift in Sources */, + 295F8FE5A9E2E8F1A6389DEE /* HomeView.swift in Sources */, + 9A04B16AEEAB1A344D13CE74 /* InstructionComposer.swift in Sources */, + E1581F8F2725B26E90D91C4A /* IntroView.swift in Sources */, + 9321A0D4BEC32E262D4A85ED /* IslandScreenGeometry.swift in Sources */, + 0417DE195518EE21267F8479 /* IslandTypes.swift in Sources */, + 8DF63B85A54090AEA72A246C /* KitPreviewView.swift in Sources */, + C3D39D96E087E98F8BF59226 /* LastTurnView.swift in Sources */, + 26E35C79CCD09FEBA04A3EF6 /* LinkTestView.swift in Sources */, + FB063FE6B8A4BB8FBAAF56CB /* LiveActivityLink.swift in Sources */, + B7C93262AA9D5DEAA0B79C7C /* MacStubs.swift in Sources */, + DBA38F8F673F436CE1D4A2E0 /* MochiActivityAttributes.swift in Sources */, + 99A1CF41C797EA7F98F2B5B1 /* MochiActivityState.swift in Sources */, + 422C116E73D2D9A96DAE104D /* MochiLive.swift in Sources */, + 11F49CD9D12E69CE18DFEDCC /* MochiOutfitDrawing.swift in Sources */, + 440AAF9FA27CC3DF7CC9EF51 /* MochiStill.swift in Sources */, + D19FA3394EE99B34184684ED /* MochiWardrobe.swift in Sources */, + 08A485F2AC63FF5298ED897B /* OpenLeadMochiIntent.swift in Sources */, + 2104B8000768F551064B17E4 /* PhoneLink.swift in Sources */, + CC81DAE27417672CB93349A5 /* PillCatalog.swift in Sources */, + 93CD58567884F5B7715D3A3E /* QuestionCard.swift in Sources */, + F9445A71BBF9E4D20224238E /* QuestionPayload.swift in Sources */, + 9BCD79FD8CF211DC8FBD2971 /* SearchAndSpotlight.swift in Sources */, + 0D1AD905638B2B021B684A09 /* ServiceDetail.swift in Sources */, + 4A32FB2EA69E8BA70EBEA8A7 /* ServiceLiveDetail.swift in Sources */, + 8C785917FD0740DAE56420A4 /* ServiceSnapshot.swift in Sources */, + E701BC8996229D08362CA732 /* ServiceStatus.swift in Sources */, + 17392C63757FBAC38F22B9D6 /* ServiceViews.swift in Sources */, + 4778852A12726803BCF8B357 /* SessionDetailView.swift in Sources */, + BCB4B7AEA1140868C36F39CC /* SessionStatus.swift in Sources */, + EFAA9936D5C9DE635CFBA2AB /* SharedSessions.swift in Sources */, + 9377B2B9A1B44E0113DBF619 /* TurnArchive.swift in Sources */, + BC6819C32C715E203254B9C6 /* TurnShare.swift in Sources */, + 65CB53E20B2289CA3AC3E8FE /* TurnSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 760473CA0E9C38B61B761B8C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -416,18 +916,21 @@ 878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */, 61CB7049490F61CFA1B36B64 /* AppLog.swift in Sources */, 61F73B27A7E3AFF584B2CC22 /* AppState.swift in Sources */, + AD94156F1F9CFF62B35202FB /* ApprovalRelay.swift in Sources */, ED5D9A334DE08C0EB81DE322 /* AskQuestion.swift in Sources */, 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */, - 0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */, + B314F09AD7924387D0E04B45 /* BotEngine.swift in Sources */, 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */, B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */, 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */, 18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */, E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */, 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */, + FDF87CF7C5F703EDD9A7CC14 /* CloudProbe.swift in Sources */, + 9ECEE890D4486B6E83BD5B7F /* ColorHex.swift in Sources */, 273FC0FA14AAF6CE62FD14AE /* DesktopMochi.swift in Sources */, C93BA975D35B8CD7473CF995 /* DesktopMochiLogic.swift in Sources */, - C1A29371181553FB4169E195 /* DiffEngine.swift in Sources */, + 4D958F2DDCE9504490FDA357 /* DiffEngine.swift in Sources */, 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */, 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */, 38EEB2D3A6EC538958D92575 /* GitHubPulse.swift in Sources */, @@ -435,27 +938,41 @@ C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */, 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */, 530443F46F896F037A56F663 /* HotKeyCenter.swift in Sources */, + 52F0DF1FBCB522951910390E /* InstructionRunner.swift in Sources */, B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */, - 0D68A4C4E3422487DB7B2262 /* IslandScreenGeometry.swift in Sources */, + CDD36B01BF50F64A2A3180EF /* IslandScreenGeometry.swift in Sources */, 3756B7E1A55D9CEC8C4D34A4 /* IslandStateMachine.swift in Sources */, - D82E93014C8087AE81511E40 /* IslandTypes.swift in Sources */, + 0685C3D47D27D06FCB6E4AD1 /* IslandTypes.swift in Sources */, 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */, C61DDE2EC5FDDB39E4AA3B34 /* IslandWindowController.swift in Sources */, + F28275101D83F121E582B1B1 /* LiveActivityRelay.swift in Sources */, E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */, - 0E6E7A12CB5CA4E499DED934 /* MochiOutfitDrawing.swift in Sources */, - F11A2EBCBF7352536B2CB93D /* MochiWardrobe.swift in Sources */, + 82E72E0AFCAB192D98AB2004 /* MochiActivityState.swift in Sources */, + ADBB9C15DDA4044625B325BE /* MochiOutfitDrawing.swift in Sources */, + 78F045A55E6F4EEA5787EEEF /* MochiStill.swift in Sources */, + A885B7E6DC43231BECE36E3D /* MochiWardrobe.swift in Sources */, 9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */, 160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */, DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */, 78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */, - E7A5D23F455565FE1E210398 /* PillCatalog.swift in Sources */, + 13663D98590927AF71C17628 /* PillCatalog.swift in Sources */, + 535EC818E1DAF0FA588545D8 /* QuestionPayload.swift in Sources */, + 9AEFD05D0A39D4AF3C091B3A /* QuestionRelay.swift in Sources */, EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */, FEFEF1EBAB0E6CA74A33C10B /* SafeWebURL.swift in Sources */, + 8C9AF392DAE8F2098A7BE7B6 /* ServiceDetail.swift in Sources */, + 4755AAC633DCE5DAA6D54A3C /* ServiceDetailRunner.swift in Sources */, + D092D0D597D1CBCE6B775B5E /* ServicePublisher.swift in Sources */, + 1FE62D4EEA85FB25C17FDBA2 /* ServiceSnapshot.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 */, + 4D34FF10654B19B69FE1964C /* TurnSnapshot.swift in Sources */, 4117E7C8AA231AAE36E7509D /* UploadCanvasView.swift in Sources */, ECECA2F225E82B7C951658D7 /* UploadSequenceEngine.swift in Sources */, 63F9F3AAC881D3EDDDD8AED9 /* UserIdentity.swift in Sources */, @@ -464,13 +981,57 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C69BB9E40CB3FDC60B8EF4C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81250AE3E83493CF806D459B /* BotEngine.swift in Sources */, + 924CE1172B5572DA3E45A7C9 /* ColorHex.swift in Sources */, + 724E602A05FE5A6D58218E27 /* CoucouControl.swift in Sources */, + 875FB3F71BD34B53B0524063 /* CoucouWidgets.swift in Sources */, + EC7904DC421C6EB6DCD77889 /* DenyApprovalIntent.swift in Sources */, + 1E55440B97C640993E93D04D /* DiffEngine.swift in Sources */, + F5D6F8E2CA65AB16B487DDD0 /* IslandScreenGeometry.swift in Sources */, + 71CBD1D39A6668BF454CA1C5 /* IslandTypes.swift in Sources */, + CE5EBAEC47402D4987EA86C7 /* MacStubs.swift in Sources */, + ADB4EF797FBD6CC90F8D25D3 /* MochiActivityAttributes.swift in Sources */, + 74E97082F2241C36E5DD22EE /* MochiActivityState.swift in Sources */, + 244EC9B77D1903C8DD505D04 /* MochiLiveActivity.swift in Sources */, + 725365FA4E184D970E7BB8B4 /* MochiOutfitDrawing.swift in Sources */, + CAC1A12153A798730E5FA835 /* MochiStill.swift in Sources */, + 053B30CF4568F7FE5138A898 /* MochiWardrobe.swift in Sources */, + D6559770BBFB06FD4E90187B /* OpenLeadMochiIntent.swift in Sources */, + 07B49D6825179DAA18DFF4D3 /* PillCatalog.swift in Sources */, + 654A8E7D8BA79AF90FA8B0DA /* QuestionPayload.swift in Sources */, + D2746E6D92F2E4C46892D5AA /* ServiceDetail.swift in Sources */, + 8881BC510155DEC038F3B552 /* ServiceSnapshot.swift in Sources */, + ED62D1B9E6A35B1F85074A2A /* SharedSessions.swift in Sources */, + F25EB7EF3662AFF99AA1C8A1 /* TeamConfiguration.swift in Sources */, + C9ED8FFDC406DD1A23AABC7C /* TurnSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 9AB649342898423F425FB7B7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 65F75F0262AF9C56FEF86BF4 /* CoucouNotificationContent */; + targetProxy = 3AA30D1108F3B5AEB81745D9 /* PBXContainerItemProxy */; + }; + D710F40BCF9AB164F9ED46E0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0387C996BACD956C91E19390 /* CoucouWidgets */; + targetProxy = 488F3E8D639E084CD826D83C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 0C135EF4AD7F69AB24CA6CEF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouReleaseCloud.entitlements; COMBINE_HIDPI_IMAGES = YES; ENABLE_SANDBOX = NO; INFOPLIST_FILE = Resources/Info.plist; @@ -481,10 +1042,234 @@ MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.NotchBuddy; PRODUCT_NAME = Coucou; + PROVISIONING_PROFILE_SPECIFIER = "Coucou Developer ID"; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = PHONE_LINK; + }; + name = Release; + }; + 0F2B6D61F557273715591EDF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoNotificationContent.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.NotificationContent; + PRODUCT_NAME = CoucouNotificationContent; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; + 109F970E00B07C14FEFE37D9 /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_CODE_SIGN_FLAGS = "--timestamp"; + OTHER_SWIFT_FLAGS = "-strict-concurrency=complete"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = ReleaseCloud; + }; + 18FBBC33FBC9D91932B78700 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouPhone.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COUCOU_APNS_ENV = development; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoPhone.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 1A702FFCBB8CA0A4927EAD22 /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoWidgets.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.Widgets; + PRODUCT_NAME = CoucouWidgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = ReleaseCloud; + }; + 1C7E2C38EAA8BEBB1449CA12 /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouReleaseCloud.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_SANDBOX = NO; + INFOPLIST_FILE = Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.NotchBuddy; + PRODUCT_NAME = Coucou; + PROVISIONING_PROFILE_SPECIFIER = "Coucou Developer ID"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = PHONE_LINK; + }; + name = ReleaseCloud; + }; + 4553E3E36A55C6B7D50AC08A /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouPhoneProduction.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COUCOU_APNS_ENV = development; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoPhone.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugCloud; + }; + 52238096CE836D5911D1CB7E /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouAppStore.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 256AUJ9555; + INFOPLIST_FILE = Resources/InfoAppStore.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = APPSTORE; + }; + name = ReleaseCloud; + }; 601A86C280BDF7077864F3F0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -503,12 +1288,97 @@ }; name = Debug; }; + 6552D5421913C5E2F9142F0B /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoNotificationContent.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.NotificationContent; + PRODUCT_NAME = CoucouNotificationContent; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugCloud; + }; + 7B74FA193F386B4CABF8D264 /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoWidgets.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.Widgets; + PRODUCT_NAME = CoucouWidgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugCloud; + }; + 84F3B137F11796B3AD8CE529 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouPhone.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COUCOU_APNS_ENV = production; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoPhone.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; 898FDF7B8955E2C2214D7618 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGNING_ALLOWED = YES; - CODE_SIGN_ENTITLEMENTS = Resources/CoucouAppStore.entitlements; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouAppStoreCloud.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -522,10 +1392,86 @@ PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; PRODUCT_NAME = Coucou; SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = APPSTORE; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APPSTORE PHONE_LINK"; }; name = Release; }; + 9998BF26354EB4899855DB48 /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouPhone.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COUCOU_APNS_ENV = production; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoPhone.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = ReleaseCloud; + }; + A7975DD38E5F3B753CE2C596 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoNotificationContent.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.NotificationContent; + PRODUCT_NAME = CoucouNotificationContent; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + AAEAD2DC7A29C9811D0FC26F /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouCloud.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_SANDBOX = NO; + INFOPLIST_FILE = Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.NotchBuddy; + PRODUCT_NAME = Coucou; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG PHONE_LINK"; + }; + name = DebugCloud; + }; B00AFE3DDD2B0870D9FC5B2A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -549,6 +1495,64 @@ }; name = Debug; }; + B6A98752A595F807FE973E78 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoWidgets.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.Widgets; + PRODUCT_NAME = CoucouWidgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + C364A8478A5883CCB9E66617 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoWidgets.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.Widgets; + PRODUCT_NAME = CoucouWidgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; CA9D2D4E8E2B8015D8EA90A2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -610,13 +1614,62 @@ ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "-strict-concurrency=complete"; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; }; name = Debug; }; + DC7F9B92D78F77B23805AE03 /* ReleaseCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = Resources/InfoNotificationContent.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + OTHER_CODE_SIGN_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou.NotificationContent; + PRODUCT_NAME = CoucouNotificationContent; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = ReleaseCloud; + }; + E330CE111096DE916D8C7361 /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGN_ENTITLEMENTS = Resources/CoucouAppStoreCloud.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 256AUJ9555; + INFOPLIST_FILE = Resources/InfoAppStore.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.louisraille.Coucou; + PRODUCT_NAME = Coucou; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APPSTORE PHONE_LINK"; + }; + name = DebugCloud; + }; ECAB43E9AE0B6830F8D4C6BA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -676,13 +1729,81 @@ OTHER_CODE_SIGN_FLAGS = "--timestamp"; OTHER_SWIFT_FLAGS = "-strict-concurrency=complete"; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; }; name = Release; }; + FBE13A39F0D6C62853811630 /* DebugCloud */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 256AUJ9555; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = "-strict-concurrency=complete"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = DebugCloud; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -690,7 +1811,9 @@ isa = XCConfigurationList; buildConfigurations = ( B00AFE3DDD2B0870D9FC5B2A /* Debug */, + E330CE111096DE916D8C7361 /* DebugCloud */, 898FDF7B8955E2C2214D7618 /* Release */, + 52238096CE836D5911D1CB7E /* ReleaseCloud */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; @@ -699,7 +1822,31 @@ isa = XCConfigurationList; buildConfigurations = ( CA9D2D4E8E2B8015D8EA90A2 /* Debug */, + FBE13A39F0D6C62853811630 /* DebugCloud */, ECAB43E9AE0B6830F8D4C6BA /* Release */, + 109F970E00B07C14FEFE37D9 /* ReleaseCloud */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C8FE04C40E517AF7D8F28493 /* Build configuration list for PBXNativeTarget "CoucouNotificationContent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A7975DD38E5F3B753CE2C596 /* Debug */, + 6552D5421913C5E2F9142F0B /* DebugCloud */, + 0F2B6D61F557273715591EDF /* Release */, + DC7F9B92D78F77B23805AE03 /* ReleaseCloud */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DA7C004DA23898CA63062F23 /* Build configuration list for PBXNativeTarget "CoucouPhone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 18FBBC33FBC9D91932B78700 /* Debug */, + 4553E3E36A55C6B7D50AC08A /* DebugCloud */, + 84F3B137F11796B3AD8CE529 /* Release */, + 9998BF26354EB4899855DB48 /* ReleaseCloud */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; @@ -708,7 +1855,20 @@ isa = XCConfigurationList; buildConfigurations = ( 601A86C280BDF7077864F3F0 /* Debug */, + AAEAD2DC7A29C9811D0FC26F /* DebugCloud */, 0C135EF4AD7F69AB24CA6CEF /* Release */, + 1C7E2C38EAA8BEBB1449CA12 /* ReleaseCloud */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + E24C031E9F3A787E693F616B /* Build configuration list for PBXNativeTarget "CoucouWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B6A98752A595F807FE973E78 /* Debug */, + 7B74FA193F386B4CABF8D264 /* DebugCloud */, + C364A8478A5883CCB9E66617 /* Release */, + 1A702FFCBB8CA0A4927EAD22 /* ReleaseCloud */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 4a1047e911..ace77cc9d2 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -262,6 +262,38 @@ 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 + + // 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 { @@ -426,6 +458,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"), @@ -454,6 +492,63 @@ 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: - 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 } @@ -644,6 +739,15 @@ 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 +} + // MARK: - Vercel struct VercelDeployment: Identifiable { @@ -654,6 +758,7 @@ struct VercelDeployment: Identifiable { let createdAt: Date let commitMessage: String? let branch: String? + var target: String? = nil // "production", "staging"… nil = preview var isSuccess: Bool { state == "READY" } var statusLabel: String { isSuccess ? "Ready" : (state == "CANCELED" ? "Canceled" : "Error") } diff --git a/NotchBuddy/Sources/App/GitHubPulse.swift b/NotchBuddy/Sources/App/GitHubPulse.swift index 6cb21d4931..0da63b3024 100644 --- a/NotchBuddy/Sources/App/GitHubPulse.swift +++ b/NotchBuddy/Sources/App/GitHubPulse.swift @@ -84,6 +84,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? { @@ -124,25 +134,36 @@ struct GitHubPulse: Equatable { } } - // 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 +263,66 @@ 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 + 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]) + } +} diff --git a/NotchBuddy/Sources/App/GithubPoller.swift b/NotchBuddy/Sources/App/GithubPoller.swift index 4cfb81d1f3..e3394df7cd 100644 --- a/NotchBuddy/Sources/App/GithubPoller.swift +++ b/NotchBuddy/Sources/App/GithubPoller.swift @@ -99,11 +99,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 } @@ -111,7 +114,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 @@ -134,9 +137,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 } @@ -290,42 +294,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 3947f0d6a6..c370892411 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -140,14 +140,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 @@ -158,7 +166,12 @@ struct OverviewView: View { if v != .overview { state.showingPlanDetail = false; activeDiffId = nil } } #endif + // 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 @@ -1662,7 +1675,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 @@ -1770,15 +1783,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) @@ -1786,7 +1798,7 @@ struct IntegrationCardView: View { } else if showingDetail && githubHasPulse { GitHubDetailView( section: githubDetailSection, - pulse: appState.githubPulse!, + pulse: appState.githubVisiblePulse!, activity: appState.githubActivity, stats: appState.githubStats, onBack: { @@ -1796,12 +1808,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) @@ -2042,7 +2063,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) { @@ -2051,12 +2076,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) @@ -2077,7 +2102,7 @@ struct VercelDeploymentListView: View { Text(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")) @@ -2092,22 +2117,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")) + 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.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) @@ -2119,81 +2155,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 { @@ -2320,6 +2281,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 { @@ -2329,9 +2294,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) }) { @@ -2436,6 +2400,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 @@ -2730,7 +2737,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") @@ -2739,7 +2746,7 @@ private func ghCIDot(_ ci: CIState) -> Color { } } -private struct GitHubPRRowView: View { +struct GitHubPRRowView: View { let pr: GitHubPR let showCI: Bool var selected: Bool = false @@ -2786,7 +2793,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..4ae4e13c95 100644 --- a/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift +++ b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift @@ -284,9 +284,9 @@ enum ServiceAPI { 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 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("https://api.vercel.com/v9/projects?limit=20", headers: headers) + 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] = [] @@ -725,7 +725,7 @@ enum ServiceAPI { 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", + 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" @@ -734,14 +734,14 @@ enum ServiceAPI { // 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", + 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("https://api.vercel.com/v12/deployments/\(target)/cancel", method: "PATCH", + _ = try await request(VercelAPI.url("https://api.vercel.com/v12/deployments/\(target)/cancel"), method: "PATCH", headers: try vercelHeaders()) return "Build canceled" case "github.rerun": diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 9a3d040817..9ed5ed2990 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -77,9 +77,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] = [] @@ -795,6 +801,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: "Projects", items: vercelProjects, @@ -815,6 +843,16 @@ struct SettingsView: View { Text("Classic token with repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") .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 @@ -1151,6 +1189,96 @@ 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 + guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member") else { return } + var req = URLRequest(url: url, timeoutInterval: 15) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + URLSession.shared.dataTask(with: req) { data, _, _ in + var names: [String] = [] + if let data, + let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + names = repos + .filter { ($0["archived"] as? Bool) != true } + .compactMap { $0["full_name"] as? String } + } + DispatchQueue.main.async { + self.githubRepos = names + self.loadingGithubRepos = false + if names.isEmpty { self.statusMessage = "❌ No GitHub repositories found." } + } + }.resume() + } + // MARK: - Vercel project list private func loadVercelProjects() { @@ -1159,7 +1287,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 @@ -1315,6 +1443,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) { @@ -1341,15 +1496,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) @@ -1357,11 +1505,22 @@ struct IntegrationFilterRow: View { } .padding(.leading, 4) if !filter.isEmpty { - Text("Watching \(filter.count) of \(items.count)") + let limit = maxSelected.map { filter.count >= $0 ? " (limit \($0))" : "" } ?? "" + Text("Watching \(filter.count) of \(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..a8104bce9b --- /dev/null +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -0,0 +1,392 @@ +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 .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 + 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 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 + + 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 } + + var body: some View { + SidePanelChrome(title: title, accent: "#F4505E", subtitle: "\(total)", + 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 + GitHubPRRowView(pr: pr, showCI: section == .myPRs, + selected: appState.cardSelection == idx) + } + ForEach(Array(repos.enumerated()), id: \.element.repo) { idx, repo in + GitHubRepoCIRowView(repo: repo, + selected: appState.cardSelection == prs.count + idx) + } + } + } + .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 + + private var accentHex: String { + switch deployment.state { + case "READY": return "#22C55E" + case "CANCELED": return "#6B7079" + default: return "#F4505E" + } + } + + 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, + 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) { + Text(deployment.commitMessage?.split(separator: "\n").first.map(String.init) ?? "No commit message") + .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")) + Button(action: { + if let url = safeWebURL("https://\(deployment.url)") { NSWorkspace.shared.open(url) } + }) { + HStack(spacing: 4) { + Text(deployment.url) + .font(.system(size: 10, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "arrow.up.right") + .font(.system(size: 7, weight: .semibold)) + } + .foregroundColor(Color(hex: "#7C5CFF").opacity(0.9)) + } + .buttonStyle(.plain) + } + } + } +} diff --git a/NotchBuddy/Sources/App/VercelPoller.swift b/NotchBuddy/Sources/App/VercelPoller.swift index 41b2492301..d4262928b5 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,13 +35,30 @@ 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() } + } + // MARK: - Poll private func poll() { 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), keep terminal ones + 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") @@ -67,7 +99,8 @@ 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) } @MainActor @@ -76,11 +109,10 @@ final class VercelPoller: @unchecked Sendable { 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 } + guard let latest = appState.vercelWatchedDeployments.first 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/tests/GitHubPulseTests.swift b/tests/GitHubPulseTests.swift index b3d7423654..696f466f07 100644 --- a/tests/GitHubPulseTests.swift +++ b/tests/GitHubPulseTests.swift @@ -343,6 +343,51 @@ 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) + } + // ── finish ───────────────────────────────────────────────────────────── if failures == 0 { print("\nAll tests passed.") From e153787909c67bb1887917b4d8754f1d036b1d12 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 19:29:00 -0600 Subject: [PATCH 2/6] Notch actions: merge, approve, re-run, redeploy and promote from the right panel - GitHub panel rows: Merge (my non-draft PRs), Approve (reviews asked of me), Re-run failed jobs (failing default-branch CI, latest failed run). - Vercel deployment panel: Open, Redeploy (ready or failed), Promote (ready, not already production). - Every action needs a second "Confirm?" click; the result shows in the panel header and the data refreshes. - ServiceAPI moved out of the PHONE_LINK-only ServiceDetailRunner into ServiceAPI.swift so the notch and the iPhone share one implementation. Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 6 + .../App/PhoneLink/ServiceDetailRunner.swift | 638 ----------------- NotchBuddy/Sources/App/ServiceAPI.swift | 655 ++++++++++++++++++ NotchBuddy/Sources/App/SidePanelView.swift | 256 ++++++- NotchBuddy/Sources/App/VercelPoller.swift | 5 + 5 files changed, 903 insertions(+), 657 deletions(-) create mode 100644 NotchBuddy/Sources/App/ServiceAPI.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index e33b7f3ffa..0af4fe60dd 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -150,7 +150,9 @@ 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 */; }; @@ -303,6 +305,7 @@ 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 = ""; }; @@ -429,6 +432,7 @@ BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */, 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */, B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, + 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, @@ -812,6 +816,7 @@ 93AB64BF47D6D1EE7DB4C22B /* QuestionRelay.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 */, @@ -960,6 +965,7 @@ 9AEFD05D0A39D4AF3C091B3A /* QuestionRelay.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 */, diff --git a/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift index 4ae4e13c95..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(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") - } - } - - 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/ServiceAPI.swift b/NotchBuddy/Sources/App/ServiceAPI.swift new file mode 100644 index 0000000000..00fdcabc24 --- /dev/null +++ b/NotchBuddy/Sources/App/ServiceAPI.swift @@ -0,0 +1,655 @@ +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)" } + } + + 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/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index a8104bce9b..eaeb8251a8 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -68,6 +68,8 @@ 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 @@ -85,7 +87,15 @@ struct SidePanelChrome: View { .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) - if let subtitle { + if let status { + Text(status.text) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(Color(hex: 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")) @@ -254,6 +264,7 @@ struct GitHubSectionPanel: View { let attention: Bool let onClose: () -> Void @ObservedObject private var appState = AppState.shared + @StateObject private var status = PanelStatusModel() private var title: String { switch section { @@ -279,8 +290,42 @@ struct GitHubSectionPanel: View { 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 && !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 { + return "No failed run to re-run" + } + return try await ServiceAPI.perform(kind: "github.rerun", target: run) + }, + onResult: finished) + } + + 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)", + SidePanelChrome(title: title, accent: "#F4505E", subtitle: "\(total)", status: status.current, attention: attention, onClose: onClose) { if total == 0 { SidePanelEmpty(text: "Nothing here") @@ -288,12 +333,18 @@ struct GitHubSectionPanel: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { ForEach(Array(prs.enumerated()), id: \.element.id) { idx, pr in - GitHubPRRowView(pr: pr, showCI: section == .myPRs, - selected: appState.cardSelection == idx) + 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 - GitHubRepoCIRowView(repo: repo, - selected: appState.cardSelection == prs.count + idx) + HStack(spacing: 6) { + GitHubRepoCIRowView(repo: repo, + selected: appState.cardSelection == prs.count + idx) + if repo.ci == .failure { rerunAction(repo) } + } } } } @@ -327,6 +378,12 @@ struct VercelDeploymentPanel: View { let deployment: VercelDeployment let attention: Bool let onClose: () -> Void + @StateObject private var status = PanelStatusModel() + + 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 accentHex: String { switch deployment.state { @@ -344,7 +401,7 @@ struct VercelDeploymentPanel: View { var body: some View { let accent = Color(hex: accentHex) SidePanelChrome( - title: deployment.projectName, accent: accentHex, + title: deployment.projectName, accent: accentHex, status: status.current, trailing: AnyView( Text(deployment.statusLabel) .font(.system(size: 9.5, weight: .medium)) @@ -372,21 +429,182 @@ struct VercelDeploymentPanel: View { } .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) - Button(action: { - if let url = safeWebURL("https://\(deployment.url)") { NSWorkspace.shared.open(url) } - }) { - HStack(spacing: 4) { - Text(deployment.url) - .font(.system(size: 10, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - Image(systemName: "arrow.up.right") - .font(.system(size: 7, weight: .semibold)) + 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) } - .foregroundColor(Color(hex: "#7C5CFF").opacity(0.9)) + 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) } - .buttonStyle(.plain) + .padding(.top, 1) } } } } + +// MARK: - Actions + +struct PanelStatus: Equatable { + let text: String + let ok: Bool +} + +/// 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) { + clear?.cancel() + withAnimation(.easeInOut(duration: 0.16)) { current = PanelStatus(text: text, ok: ok) } + 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 + + 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 { + 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) + } +} diff --git a/NotchBuddy/Sources/App/VercelPoller.swift b/NotchBuddy/Sources/App/VercelPoller.swift index d4262928b5..59a939e63d 100644 --- a/NotchBuddy/Sources/App/VercelPoller.swift +++ b/NotchBuddy/Sources/App/VercelPoller.swift @@ -44,6 +44,11 @@ final class VercelPoller: @unchecked Sendable { 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() { From e416d2530bf78dced5dcf67c550ce269f1f0e9f5 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 19:42:19 -0600 Subject: [PATCH 3/6] =?UTF-8?q?Phase=203:=20Vercel=20builds=20in=20progres?= =?UTF-8?q?s,=20failure=20reasons,=20session=20=E2=86=92=20PR,=20PR=20?= =?UTF-8?q?=E2=86=92=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vercel: builds in progress are kept (amber, "building…"), with Cancel build in their panel; the finished alert fires once per finished deployment, including one that was building. A failed deployment's panel shows Vercel's errorMessage, else the most relevant build-log line. - Claude Code sessions: GitRepoInfo reads the repo and branch from the session's .git (worktrees included); SessionGitHubLinker looks up that branch's open PR and CI while the island is open on the session (GraphQL variables, fork PRs with the same branch name ignored). A chip next to the session name opens a panel with the PR (Merge), the checks (Re-run) and the Vercel preview. - My PRs: a ▲ opens the PR's Vercel preview when one matches its branch. - iPhone: builds in progress read as "Building", not as failures. - Tests: GitRepoInfoTests (+ scripts/test-git-repo-info.sh), SessionBranchInfo. Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 12 + NotchBuddy/Sources/App/AppState.swift | 41 ++- NotchBuddy/Sources/App/GitHubPulse.swift | 73 +++++- NotchBuddy/Sources/App/GitRepoInfo.swift | 98 ++++++++ .../Sources/App/IslandViewContent.swift | 22 +- .../App/PhoneLink/ServicePublisher.swift | 3 +- NotchBuddy/Sources/App/ServiceAPI.swift | 30 +++ .../Sources/App/SessionGitHubLinker.swift | 57 +++++ NotchBuddy/Sources/App/SidePanelView.swift | 236 +++++++++++++++++- NotchBuddy/Sources/App/VercelPoller.swift | 31 ++- scripts/test-git-repo-info.sh | 8 + tests/GitHubPulseTests.swift | 34 +++ tests/GitRepoInfoTests.swift | 64 +++++ 13 files changed, 682 insertions(+), 27 deletions(-) create mode 100644 NotchBuddy/Sources/App/GitRepoInfo.swift create mode 100644 NotchBuddy/Sources/App/SessionGitHubLinker.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 0af4fe60dd..22eafcb6be 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 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 */; }; @@ -120,10 +121,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 */; }; @@ -146,6 +149,7 @@ 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 */; }; @@ -325,9 +329,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 = ""; }; 8ACB4350729C6513824D1ADF /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHex.swift; sourceTree = ""; }; @@ -418,6 +424,7 @@ 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */, + 85588295C63115CAF2C6C8E2 /* GitRepoInfo.swift */, C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */, 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */, 1218B86E249691AF44F819AC /* HotKeyCenter.swift */, @@ -433,6 +440,7 @@ 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */, B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */, + 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, @@ -790,6 +798,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 */, @@ -821,6 +830,7 @@ 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 */, @@ -939,6 +949,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 */, @@ -970,6 +981,7 @@ 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 */, diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index ace77cc9d2..0fa2aeea2b 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -289,6 +289,10 @@ final class AppState: ObservableObject { // 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") } @@ -500,6 +504,21 @@ final class AppState: ObservableObject { 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). @@ -746,6 +765,7 @@ enum SidePanel: Equatable { 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 @@ -759,9 +779,28 @@ struct VercelDeployment: Identifiable { 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 0da63b3024..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 @@ -129,7 +130,8 @@ 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 )) } } @@ -293,7 +295,7 @@ extension GitHubPulse { login pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { - number title url isDraft reviewDecision + number title url isDraft reviewDecision headRefName repository { nameWithOwner url } commits(last: 1) { nodes { commit { oid statusCheckRollup { state } } } @@ -326,3 +328,70 @@ extension GitHubPulse { 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/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index c370892411..d66e90b6bf 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -74,6 +74,12 @@ struct OverviewView: View { .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1) .truncationMode(.tail) + if let git = state.sessionGit[agent.id] { + SessionBranchChip(git: git, info: state.sessionBranchInfo(for: agent.id), + isOpen: state.sidePanel == .sessionBranch(agent.id)) { + state.toggleSidePanel(.sessionBranch(agent.id)) + } + } Spacer(minLength: 2) if agent.steps.count > 1 { Text("\(min(agent.stepIndex + 1, agent.steps.count))/\(agent.steps.count)") @@ -166,6 +172,14 @@ 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 } @@ -2091,7 +2105,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) @@ -2099,7 +2113,7 @@ 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(first.id) }) { @@ -2126,7 +2140,7 @@ struct VercelDeploymentListView: View { // Remaining deployments — plain rows, identical structure → perfect alignment ForEach(Array(deployments.dropFirst().prefix(2))) { dep in - let accent = Color(hex: dep.isSuccess ? "#22C55E" : "#F4505E") + let accent = Color(hex: dep.stateColor) Button(action: { onOpenDetail(dep.id) }) { HStack(spacing: 5) { Circle().fill(accent).frame(width: 5, height: 5) @@ -2135,7 +2149,7 @@ struct VercelDeploymentListView: View { .foregroundColor(Color(hex: "#9398A1")) .lineLimit(1).truncationMode(.tail) .layoutPriority(1) - Text(dep.timeAgo) + Text(dep.isBuilding ? dep.statusLabel.lowercased() + "…" : dep.timeAgo) .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) } 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 index 00fdcabc24..48a6643df7 100644 --- a/NotchBuddy/Sources/App/ServiceAPI.swift +++ b/NotchBuddy/Sources/App/ServiceAPI.swift @@ -642,6 +642,36 @@ enum ServiceAPI { 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"] diff --git a/NotchBuddy/Sources/App/SessionGitHubLinker.swift b/NotchBuddy/Sources/App/SessionGitHubLinker.swift new file mode 100644 index 0000000000..bcb5f94ed0 --- /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, task.source == .claudeCode, !task.isIntegration, + 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/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index eaeb8251a8..92782e73aa 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -47,6 +47,8 @@ struct SidePanelView: View { 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) @@ -293,6 +295,9 @@ struct GitHubSectionPanel: View { @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)", @@ -379,20 +384,19 @@ struct VercelDeploymentPanel: View { 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 accentHex: String { - switch deployment.state { - case "READY": return "#22C55E" - case "CANCELED": return "#6B7079" - default: return "#F4505E" - } + 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 @@ -413,11 +417,21 @@ struct VercelDeploymentPanel: View { attention: attention, onClose: onClose ) { VStack(alignment: .leading, spacing: 5) { - Text(deployment.commitMessage?.split(separator: "\n").first.map(String.init) ?? "No commit message") - .font(.system(size: 11)) - .foregroundColor(Color(hex: deployment.commitMessage == nil ? "#6B7079" : "#C5C8CD")) - .lineLimit(1) - .truncationMode(.tail) + 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") @@ -443,6 +457,12 @@ struct VercelDeploymentPanel: View { }, 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", @@ -458,6 +478,12 @@ struct VercelDeploymentPanel: View { .padding(.top, 1) } } + .task(id: deployment.id) { + guard deployment.state == "ERROR" else { return } + failureReason = nil + let reason = try? await ServiceAPI.vercelFailureReason(id: deployment.id) + failureReason = reason ?? "No error message from Vercel" + } } } @@ -608,3 +634,191 @@ struct PanelLinkButton: View { .help(help ?? title) } } + +// MARK: - Claude Code session → branch + +/// 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 { + return "No failed run to re-run" + } + return try await ServiceAPI.perform(kind: "github.rerun", target: run) + }, + onResult: finished) + } + } + .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("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: "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 preview · \(deployment.statusLabel) · \(deployment.url)") + } +} diff --git a/NotchBuddy/Sources/App/VercelPoller.swift b/NotchBuddy/Sources/App/VercelPoller.swift index 59a939e63d..863801401f 100644 --- a/NotchBuddy/Sources/App/VercelPoller.swift +++ b/NotchBuddy/Sources/App/VercelPoller.swift @@ -62,7 +62,7 @@ final class VercelPoller: @unchecked Sendable { guard wanted else { return } #endif - // Fetch the last 20 deployments (enough to cover a few watched projects), keep terminal ones + // Fetch the last 20 deployments (enough to cover a few watched projects) 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") @@ -75,11 +75,10 @@ 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) } @@ -89,7 +88,7 @@ final class VercelPoller: @unchecked Sendable { 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 @@ -105,7 +104,22 @@ final class VercelPoller: @unchecked Sendable { return VercelDeployment(id: uid, projectName: name, url: url, state: state, createdAt: createdAt, commitMessage: commitMessage, branch: branch, - target: d["target"] as? String) + 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 @@ -113,8 +127,9 @@ final class VercelPoller: @unchecked Sendable { let appState = AppState.shared appState.vercelDeployments = deployments - // Apply project filter (empty = all projects) - guard let latest = appState.vercelWatchedDeployments.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 } 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 696f466f07..a3f8704394 100644 --- a/tests/GitHubPulseTests.swift +++ b/tests/GitHubPulseTests.swift @@ -388,6 +388,40 @@ enum GitHubPulseTests { 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 f1bd8d3727eac0fc9656a4e438c59a68afee16cb Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 20:26:13 -0600 Subject: [PATCH 4/6] Terminal Claude Code sessions and replying from the notch - Claude Code sessions run in a terminal (Warp, Terminal, iTerm, Ghostty, kitty, Alacritty, WezTerm, Hyper, Zed) now route to the Claude Code pill, including approval and question cards. The pill says "Claude Code" and its open button brings the terminal forward (ClaudeHost). - Reply panel: continue the focused Claude Code session from the notch with `claude -p --resume` in the background. SessionResumer is shared with the iPhone instructions and keeps the hooks on the same pill and host. - The session branch/PR chip now also shows on the Claude Code pill's card. Co-Authored-By: Claude Opus 5.5 --- .../NotchBuddy.xcodeproj/project.pbxproj | 12 ++ NotchBuddy/Sources/App/AppState.swift | 3 + NotchBuddy/Sources/App/ClaudeHost.swift | 77 ++++++++++ NotchBuddy/Sources/App/HookServer.swift | 43 ++++-- .../Sources/App/IslandViewContent.swift | 23 +-- .../Sources/App/IslandWindowController.swift | 7 + .../App/PhoneLink/InstructionRunner.swift | 81 +--------- NotchBuddy/Sources/App/SessionResumer.swift | 112 ++++++++++++++ NotchBuddy/Sources/App/SidePanelView.swift | 143 ++++++++++++++++++ .../Sources/CoucouKit/IslandTypes.swift | 1 + 10 files changed, 405 insertions(+), 97 deletions(-) create mode 100644 NotchBuddy/Sources/App/ClaudeHost.swift create mode 100644 NotchBuddy/Sources/App/SessionResumer.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 22eafcb6be..d312041dbd 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -41,6 +41,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 */; }; @@ -115,12 +116,14 @@ 81250AE3E83493CF806D459B /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; 8173F93555ED400954AA1CB3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097F57179CC066459E64303A /* AboutView.swift */; }; 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, ); }; }; 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 */; }; 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 */; }; @@ -233,6 +236,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 */; }; @@ -304,6 +308,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 = ""; }; @@ -365,6 +370,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 +421,7 @@ 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, + D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */, F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */, 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, @@ -441,6 +448,7 @@ B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, 57BDE57986F4B4D2FC3A0358 /* ServiceAPI.swift */, 7F5EB9E84BA39B03DFBC3EC1 /* SessionGitHubLinker.swift */, + 49393F75E83B2BF1EA7C53C8 /* SessionResumer.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, @@ -787,6 +795,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 */, @@ -832,6 +841,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 */, @@ -938,6 +948,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 */, @@ -983,6 +994,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 0fa2aeea2b..5ac603cefd 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -292,6 +292,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 { @@ -766,6 +768,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 new file mode 100644 index 0000000000..7663a46497 --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudeHost.swift @@ -0,0 +1,77 @@ +import AppKit + +// MARK: - ClaudeHost +// +// The app a Claude Code session runs in, from the hook payload's term_program / +// bundle_id. VS Code sessions keep the original "VS Code" pill; sessions from a +// terminal also route to integration_claude and the pill says "Claude Code". + +/// The Claude Code session last seen on a pill, from the hooks. +struct ClaudeSessionRef: Equatable { + let sessionId: String + let cwd: String + let hostApp: String? // bundle id of the terminal, nil = VS Code +} + +struct ClaudeHost: Equatable { + let bundleId: String + let name: String + + /// 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(for task: AgentTask) -> String { + task.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(options: .activateIgnoringOtherApps) + } 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 26dde14cc2..c070456df6 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -351,6 +351,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 @@ -358,6 +359,7 @@ final class HookServer: @unchecked Sendable { #endif let agentId: String let isExternalAgent: Bool + var hostApp: String? = nil if isCodexEvent { agentId = "agent_codex" isExternalAgent = false @@ -370,6 +372,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 @@ -377,6 +383,13 @@ final class HookServer: @unchecked Sendable { let focused = state.focusId == agentId + // 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) } @@ -389,7 +402,7 @@ final class HookServer: @unchecked Sendable { switch pending.pillId { case "agent_cursor": handledNote = "Handled in Cursor." case "agent_codex": handledNote = "Handled in Codex." - default: handledNote = "Handled in VS Code." + default: handledNote = "Handled in \(claudeHostName)." } var resolved = false switch name { @@ -418,7 +431,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)))") if state.isPresent { expandIfNeeded(to: .overview) } @@ -426,7 +439,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 { @@ -441,7 +454,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 = frenchStep(tool: tool, input: input) @@ -648,7 +661,8 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - guard isCodexRequest || isCursorEditor || isVSCodeEditor else { + let isTerminal = ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) != nil + guard isCodexRequest || isCursorEditor || isVSCodeEditor || isTerminal else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) @@ -712,7 +726,7 @@ final class HookServer: @unchecked Sendable { switch capturedPillId { case "agent_cursor": note = "Handled in Cursor." case "agent_codex": note = "Handled in Codex." - default: note = "Handled in VS Code." + default: note = "Handled in \(self.claudeHostName)." } self.dismissApprovalCard(note: note) } @@ -729,7 +743,7 @@ final class HookServer: @unchecked Sendable { switch capturedPillId { case "agent_cursor": note = "Still waiting in Cursor." case "agent_codex": note = "Still waiting in Codex." - default: note = "Still waiting in VS Code." + default: note = "Still waiting in \(self.claudeHostName)." } self.dismissApprovalCard(note: note) } @@ -811,7 +825,8 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - guard isCodexRequest || isCursorEditor || isVSCodeEditor else { + let isTerminal = ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) != nil + guard isCodexRequest || isCursorEditor || isVSCodeEditor || isTerminal else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) @@ -871,20 +886,28 @@ final class HookServer: @unchecked Sendable { /// 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. + /// "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) + } + @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) + 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 d66e90b6bf..8c40d8cfd0 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -74,12 +74,7 @@ struct OverviewView: View { .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1) .truncationMode(.tail) - if let git = state.sessionGit[agent.id] { - SessionBranchChip(git: git, info: state.sessionBranchInfo(for: agent.id), - isOpen: state.sidePanel == .sessionBranch(agent.id)) { - state.toggleSidePanel(.sessionBranch(agent.id)) - } - } + 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)") @@ -208,6 +203,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) @@ -1873,6 +1869,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)") @@ -1900,7 +1897,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(for: task) + : 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") @@ -1922,7 +1920,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)) @@ -3725,9 +3728,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(for: task) : task.name } var body: some View { diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index c49e37ffb1..97fbfe3e2c 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) @@ -156,6 +157,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 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/SessionResumer.swift b/NotchBuddy/Sources/App/SessionResumer.swift new file mode 100644 index 0000000000..71b548a645 --- /dev/null +++ b/NotchBuddy/Sources/App/SessionResumer.swift @@ -0,0 +1,112 @@ +#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) + process.arguments = ["-p", text, "--resume", session.sessionId] + 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 92782e73aa..b87db23b77 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) @@ -638,6 +644,28 @@ struct PanelLinkButton: View { // MARK: - Claude Code session → branch /// Next to the session name: "#12" with the PR's CI dot, or the branch when it has no open 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 + + 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)) + } + } + #if !APPSTORE + if state.claudeSessions[taskId] != nil { + SessionReplyChip(isOpen: state.sidePanel == .reply(taskId)) { + state.toggleSidePanel(.reply(taskId)) + } + } + #endif + } +} + struct SessionBranchChip: View { let git: GitRepoInfo let info: SessionBranchInfo? @@ -822,3 +850,118 @@ struct VercelPreviewDot: View { .help("Vercel 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, _ in + if code != 0 { SoundEngine.shared.play("error") } + }) + 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 diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 60fbbe1a04..2347621a88 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 { From a2dc458a8bd8eadf778d092ce458b5de576fac1b Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 20:58:23 -0600 Subject: [PATCH 5/6] Review fixes: session PR lookup, reply panel, Vercel and GitHub details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session → PR/CI never ran: the linker skipped integration tasks, and every Claude Code pill is one. The branch chip, its panel and the preview row now work on the Claude Code pill. - Vercel panel: the failure reason is looked up again when a build fails while the panel is open. - Reply panel: the island no longer auto-collapses while it is open, ⌘ + arrows move the text cursor instead of switching pills, and the message goes after "--" so text starting with "-" isn't read as an option. - "No failed run to re-run" shows as a grey notice instead of a success. - A production deployment on the session's branch is labelled Production. - Vercel: a poll answer from the previous team scope is dropped. - Settings: the repository list loads up to 500 repos (5 pages). - Cleanup: unused TurnRecorder.lastSession removed, doc comments fixed. Co-Authored-By: Claude Opus 5.5 --- .../Sources/App/IslandStateMachine.swift | 3 ++ .../Sources/App/IslandWindowController.swift | 7 ++++ .../Sources/App/PhoneLink/TurnRecorder.swift | 9 ---- .../Sources/App/SessionGitHubLinker.swift | 2 +- NotchBuddy/Sources/App/SessionResumer.swift | 3 +- NotchBuddy/Sources/App/SettingsView.swift | 29 ++++++------- NotchBuddy/Sources/App/SidePanelView.swift | 42 +++++++++++++------ NotchBuddy/Sources/App/VercelPoller.swift | 7 +++- 8 files changed, 64 insertions(+), 38 deletions(-) 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 97fbfe3e2c..ef5bdb429d 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -212,6 +212,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 @@ -461,6 +465,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/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/SessionGitHubLinker.swift b/NotchBuddy/Sources/App/SessionGitHubLinker.swift index bcb5f94ed0..82bbe2445d 100644 --- a/NotchBuddy/Sources/App/SessionGitHubLinker.swift +++ b/NotchBuddy/Sources/App/SessionGitHubLinker.swift @@ -14,7 +14,7 @@ final class SessionGitHubLinker { private init() {} func refresh(for task: AgentTask?) { - guard let task, task.source == .claudeCode, !task.isIntegration, + guard let task, task.source == .claudeCode, let cwd = task.sessionCwd, !cwd.isEmpty else { return } let state = AppState.shared guard let git = GitRepoInfo.read(cwd: cwd) else { diff --git a/NotchBuddy/Sources/App/SessionResumer.swift b/NotchBuddy/Sources/App/SessionResumer.swift index 71b548a645..906f76c99a 100644 --- a/NotchBuddy/Sources/App/SessionResumer.swift +++ b/NotchBuddy/Sources/App/SessionResumer.swift @@ -46,7 +46,8 @@ final class SessionResumer { let process = Process() process.executableURL = URL(fileURLWithPath: claude) - process.arguments = ["-p", text, "--resume", session.sessionId] + // "--" 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 diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 9ed5ed2990..5301a04d36 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -1259,24 +1259,25 @@ struct SettingsView: View { return } loadingGithubRepos = true - guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member") else { return } - var req = URLRequest(url: url, timeoutInterval: 15) - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - URLSession.shared.dataTask(with: req) { data, _, _ in + Task { + // Up to 5 pages of 100, most recently pushed first var names: [String] = [] - if let data, - let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { - names = repos + 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 } } - DispatchQueue.main.async { - self.githubRepos = names - self.loadingGithubRepos = false - if names.isEmpty { self.statusMessage = "❌ No GitHub repositories found." } - } - }.resume() + githubRepos = names + loadingGithubRepos = false + if names.isEmpty { statusMessage = "❌ No GitHub repositories found." } + } } // MARK: - Vercel project list diff --git a/NotchBuddy/Sources/App/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index b87db23b77..04f715985b 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -98,7 +98,7 @@ struct SidePanelChrome: View { if let status { Text(status.text) .font(.system(size: 10.5, weight: .medium)) - .foregroundColor(Color(hex: status.ok ? "#22C55E" : "#F4505E")) + .foregroundColor(Color(hex: status.neutral ? "#9398A1" : status.ok ? "#22C55E" : "#F4505E")) .lineLimit(1) .truncationMode(.tail) .help(status.text) @@ -322,11 +322,12 @@ struct GitHubSectionPanel: View { 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 { - return "No failed run to re-run" + throw PanelNotice(message: "No failed run to re-run") } return try await ServiceAPI.perform(kind: "github.rerun", target: run) }, - onResult: finished) + onResult: finished, + onNotice: { status.show($0, ok: true, neutral: true) }) } private func finished(_ message: String, _ ok: Bool) { @@ -484,7 +485,8 @@ struct VercelDeploymentPanel: View { .padding(.top, 1) } } - .task(id: deployment.id) { + // 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) @@ -498,6 +500,13 @@ struct VercelDeploymentPanel: View { 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. @@ -506,9 +515,9 @@ final class PanelStatusModel: ObservableObject { @Published var current: PanelStatus? = nil private var clear: DispatchWorkItem? - func show(_ text: String, ok: Bool) { + func show(_ text: String, ok: Bool, neutral: Bool = false) { clear?.cancel() - withAnimation(.easeInOut(duration: 0.16)) { current = PanelStatus(text: text, ok: ok) } + 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 } } @@ -526,6 +535,8 @@ struct PanelActionButton: View { 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 @@ -563,6 +574,11 @@ struct PanelActionButton: View { 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") @@ -643,7 +659,6 @@ struct PanelLinkButton: View { // MARK: - Claude Code session → branch -/// Next to the session name: "#12" with the PR's CI dot, or the branch when it has no open PR. /// The chips after a session's name: its branch/PR, and the reply button (GitHub build). struct SessionChips: View { @ObservedObject var state: AppState @@ -666,6 +681,7 @@ struct SessionChips: View { } } +/// 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? @@ -789,11 +805,12 @@ struct SessionBranchPanel: View { 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 { - return "No failed run to re-run" + throw PanelNotice(message: "No failed run to re-run") } return try await ServiceAPI.perform(kind: "github.rerun", target: run) }, - onResult: finished) + onResult: finished, + onNotice: { status.show($0, ok: true, neutral: true) }) } } .frame(height: 20) @@ -803,7 +820,7 @@ struct SessionBranchPanel: View { 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("Vercel") + Text(dep.target == "production" ? "Production" : "Vercel") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#9398A1")) Text("\(dep.statusLabel) · \(dep.isBuilding ? "now" : dep.timeAgo)") @@ -811,7 +828,8 @@ struct SessionBranchPanel: View { .foregroundColor(Color(hex: "#C5C8CD")) .lineLimit(1) Spacer(minLength: 4) - PanelLinkButton(title: "Preview", accent: "#7C5CFF", help: dep.url) { + PanelLinkButton(title: dep.target == "production" ? "Open" : "Preview", + accent: "#7C5CFF", help: dep.url) { if let url = safeWebURL("https://\(dep.url)") { NSWorkspace.shared.open(url) } } } @@ -847,7 +865,7 @@ struct VercelPreviewDot: View { } .buttonStyle(.plain) .onHover { isHovered = $0 } - .help("Vercel preview · \(deployment.statusLabel) · \(deployment.url)") + .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 863801401f..bad838f311 100644 --- a/NotchBuddy/Sources/App/VercelPoller.swift +++ b/NotchBuddy/Sources/App/VercelPoller.swift @@ -63,6 +63,7 @@ final class VercelPoller: @unchecked Sendable { #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") @@ -81,7 +82,11 @@ final class VercelPoller: @unchecked Sendable { .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() } From b831da31e1c20290fed85e342298bb319c73c0e8 Mon Sep 17 00:00:00 2001 From: santiagoOrozco-encora Date: Tue, 6 Oct 2026 21:14:35 -0600 Subject: [PATCH 6/6] Terminal sessions ask in the terminal by default, second review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New setting (off by default): "Answer questions and permissions from terminal sessions in the notch". Off: terminal sessions still show in the notch, but their questions and permission requests go straight back to the terminal. On: the notch shows them first, as for VS Code. - Approvals and questions from a terminal no longer flip the Claude Code pill back to "VS Code" (the host was dropped on those paths). - A failed reply from the notch shows "⚠ Reply failed: …" as the session's last line instead of only an error sound. - The branch/PR chip now works for every workspace session with a folder, Cursor included. - ClaudeHost no longer depends on AgentTask; new tests (scripts/test-claude-host.sh); deprecated activation call replaced; misplaced doc comment fixed. Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/ClaudeHost.swift | 11 +++-- NotchBuddy/Sources/App/HookServer.swift | 22 ++++++---- .../Sources/App/IslandViewContent.swift | 4 +- .../Sources/App/SessionGitHubLinker.swift | 2 +- NotchBuddy/Sources/App/SettingsView.swift | 6 +++ NotchBuddy/Sources/App/SidePanelView.swift | 10 ++++- scripts/test-claude-host.sh | 8 ++++ tests/ClaudeHostTests.swift | 40 +++++++++++++++++++ 8 files changed, 87 insertions(+), 16 deletions(-) create mode 100755 scripts/test-claude-host.sh create mode 100644 tests/ClaudeHostTests.swift diff --git a/NotchBuddy/Sources/App/ClaudeHost.swift b/NotchBuddy/Sources/App/ClaudeHost.swift index 7663a46497..e8dff81bfa 100644 --- a/NotchBuddy/Sources/App/ClaudeHost.swift +++ b/NotchBuddy/Sources/App/ClaudeHost.swift @@ -17,6 +17,11 @@ 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] = [ @@ -59,8 +64,8 @@ struct ClaudeHost: Equatable { } /// Pill label for integration_claude: "VS Code" for editor sessions, "Claude Code" otherwise. - static func pillName(for task: AgentTask) -> String { - task.hostApp == nil ? "VS Code" : "Claude Code" + 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. @@ -68,7 +73,7 @@ struct ClaudeHost: Equatable { 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(options: .activateIgnoringOtherApps) + running.activate() } else if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id) { NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) } diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index c070456df6..50f7ad0fc3 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -661,7 +661,10 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - let isTerminal = ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) != nil + // 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"}"#) @@ -703,7 +706,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) @@ -825,7 +828,10 @@ final class HookServer: @unchecked Sendable { } else { pillId = "integration_claude" } - let isTerminal = ClaudeHost.terminal(termProgram: termProgram, bundleId: bundleId) != nil + // 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"}"#) @@ -848,7 +854,7 @@ final class HookServer: @unchecked Sendable { activeSessionId = sessionId questionPillId = pillId - 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 @@ -883,15 +889,15 @@ final class HookServer: @unchecked Sendable { } } - /// 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. /// "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 = "", hostApp: String? = nil) { let state = AppState.shared @@ -907,7 +913,7 @@ final class HookServer: @unchecked Sendable { let source = def?.source ?? .agent var task = AgentTask(id: id, name: projectName, color: color, state: .idle, steps: [], source: source, isIntegration: true) - task.hostApp = hostApp + 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 8c40d8cfd0..9bff373074 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -1897,7 +1897,7 @@ struct IntegrationCardView: View { Circle() .fill(Color(hex: task.color)) .frame(width: 7, height: 7) - Text(task.id == "integration_claude" ? ClaudeHost.pillName(for: task) + 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")) @@ -3730,7 +3730,7 @@ struct AgentPill: View { // 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" ? ClaudeHost.pillName(for: task) : task.name + task.id == "integration_claude" ? ClaudeHost.pillName(hostApp: task.hostApp) : task.name } var body: some View { diff --git a/NotchBuddy/Sources/App/SessionGitHubLinker.swift b/NotchBuddy/Sources/App/SessionGitHubLinker.swift index 82bbe2445d..989d5f0635 100644 --- a/NotchBuddy/Sources/App/SessionGitHubLinker.swift +++ b/NotchBuddy/Sources/App/SessionGitHubLinker.swift @@ -14,7 +14,7 @@ final class SessionGitHubLinker { private init() {} func refresh(for task: AgentTask?) { - guard let task, task.source == .claudeCode, + 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 { diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 5301a04d36..759cb75377 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -106,6 +106,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 ?? "" @@ -414,6 +415,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/App/SidePanelView.swift b/NotchBuddy/Sources/App/SidePanelView.swift index 04f715985b..453a627d1b 100644 --- a/NotchBuddy/Sources/App/SidePanelView.swift +++ b/NotchBuddy/Sources/App/SidePanelView.swift @@ -925,8 +925,14 @@ struct SessionReplyPanel: View { 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, _ in - if code != 0 { SoundEngine.shared.play("error") } + }, 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: 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.") + } +}