From 3c8a9a1c12065d6ba4bf77391f5458c2def0d450 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Tue, 6 Oct 2026 22:03:58 +0300 Subject: [PATCH] Home: two floors of flat app pills (Claude, ChatGPT, two empty slots); hide VS Code and service pills Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/AppGridView.swift | 161 ++++++++++++++++++ NotchBuddy/Sources/App/AppState.swift | 8 +- .../Sources/App/IslandViewContent.swift | 8 +- 3 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 NotchBuddy/Sources/App/AppGridView.swift diff --git a/NotchBuddy/Sources/App/AppGridView.swift b/NotchBuddy/Sources/App/AppGridView.swift new file mode 100644 index 0000000..0fb700f --- /dev/null +++ b/NotchBuddy/Sources/App/AppGridView.swift @@ -0,0 +1,161 @@ +import SwiftUI +import AppKit + +// MARK: - BroV home: two floors of flat app pills +// +// Top floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). +// Bottom floor: two empty slots, to be filled later. The bot keeps its spot on the left. + +struct DesktopAppSlot: Identifiable { + let id: String + let name: String + let bundleId: String + let color: String + let downloadURL: String +} + +enum DesktopApps { + static let claude = DesktopAppSlot(id: "app_claude", name: "Claude", + bundleId: "com.anthropic.claudefordesktop", + color: "#D97757", downloadURL: "https://claude.ai/download") + static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT", + bundleId: "com.openai.chat", + color: "#10A37F", downloadURL: "https://chatgpt.com/download") +} + +struct AppGridView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + VStack(spacing: 8) { + HStack(spacing: 8) { + AppPill(app: DesktopApps.claude) + AppPill(app: DesktopApps.chatGPT) + } + HStack(spacing: 8) { + EmptyPill() + EmptyPill() + } + } + .padding(.leading, 108) + .padding(.trailing, 14) + .padding(.vertical, 6) + + #if !APPSTORE + if state.showingPlanDetail { + CardBackground(wash: nil) + ClaudePlanCardView(usage: state.claudePlanUsage) + .transition(.opacity) + } + #endif + } + #if !APPSTORE + .onChange(of: state.mode) { _, m in + if m != .expanded { state.showingPlanDetail = false } + } + #endif + } +} + +// MARK: - App pill + +struct AppPill: View { + let app: DesktopAppSlot + @State private var appURL: URL? + @State private var isRunning = false + @State private var isHovered = false + + private var status: String { + if appURL == nil { return "не установлено" } + return isRunning ? "запущено" : "не запущено" + } + + var body: some View { + Button(action: open) { + HStack(spacing: 10) { + icon + .frame(width: 26, height: 26) + VStack(alignment: .leading, spacing: 1) { + Text(app.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + HStack(spacing: 5) { + Circle() + .fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D")) + .frame(width: 5, height: 5) + Text(status) + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#8E939C")) + } + } + Spacer(minLength: 4) + Image(systemName: "arrow.up.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D")) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isHovered ? Color(hex: app.color).opacity(0.16) : Color(hex: "#0E0F11")) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.02 : 1) + .onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } } + .onAppear(perform: refresh) + .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { _ in refresh() } + .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() } + } + + @ViewBuilder private var icon: some View { + if let appURL { + Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) + .resizable() + .interpolation(.high) + } else { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(hex: app.color)) + .overlay(Text(String(app.name.prefix(1))) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white)) + } + } + + private func refresh() { + appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: app.bundleId) + isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == app.bundleId } + } + + private func open() { + SoundEngine.shared.play("blip") + if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == app.bundleId }) { + running.activate(options: .activateIgnoringOtherApps) + } else if let appURL { + NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil) + } else if let url = URL(string: app.downloadURL) { + NSWorkspace.shared.open(url) + } + } +} + +// MARK: - Empty slot + +struct EmptyPill: View { + var body: some View { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .overlay( + Text("пусто") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#4A4E55")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index b693a5c..7c3d9ca 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -292,7 +292,8 @@ final class AppState: ObservableObject { } // Active integration pills (main workspace pill excluded). Max 4. - @Published var activeIntegrations: Set = ["integration_resend", "integration_n8n", "integration_vercel", "integration_github"] { + // BroV: no service pills by default (add them back in Settings → Pills). + @Published var activeIntegrations: Set = [] { didSet { if let data = try? JSONEncoder().encode(Array(activeIntegrations)) { UserDefaults.standard.set(data, forKey: "activeIntegrations") @@ -452,6 +453,11 @@ final class AppState: ObservableObject { let a = try? JSONDecoder().decode([String].self, from: d) { vercelProjectFilter = Set(a) } if let d = ud.data(forKey: "n8nWorkflowFilter"), let a = try? JSONDecoder().decode([String].self, from: d) { n8nWorkflowFilter = Set(a) } + // BroV home redesign: drop the four default service pills once. + if !ud.bool(forKey: "brovServicePillsCleared") { + ud.removeObject(forKey: "activeIntegrations") + ud.set(true, forKey: "brovServicePillsCleared") + } if let d = ud.data(forKey: "activeIntegrations"), let a = try? JSONDecoder().decode([String].self, from: d) { activeIntegrations = Set(a) } if let v = ud.string(forKey: "mainPill"), !v.isEmpty, diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 63be229..378e100 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -8,8 +8,9 @@ struct IslandViewContent: View { var body: some View { switch view { - case .overview: OverviewView(state: state) - case .empty: EmptyStateView(state: state) + // BroV home: two floors of flat app pills (Claude, ChatGPT, two free slots). + case .overview: AppGridView(state: state) + case .empty: AppGridView(state: state) case .approval: ApprovalView(state: state) case .question: QuestionView(state: state) case .error: ErrorView(state: state) @@ -4001,8 +4002,9 @@ struct PillBadgeView: View { struct ColumnAgentsView: View { @ObservedObject var state: AppState + // BroV: the VS Code container pill (integration_claude) stays internal, never shown. var others: [AgentTask] { - state.tasks.filter { $0.id != state.focusId } + state.tasks.filter { $0.id != state.focusId && $0.id != "integration_claude" } } var body: some View {