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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,7 +292,8 @@ final class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Active integration pills (main workspace pill excluded). Max 4.
|
// Active integration pills (main workspace pill excluded). Max 4.
|
||||||
@Published var activeIntegrations: Set<String> = ["integration_resend", "integration_n8n", "integration_vercel", "integration_github"] {
|
// BroV: no service pills by default (add them back in Settings → Pills).
|
||||||
|
@Published var activeIntegrations: Set<String> = [] {
|
||||||
didSet {
|
didSet {
|
||||||
if let data = try? JSONEncoder().encode(Array(activeIntegrations)) {
|
if let data = try? JSONEncoder().encode(Array(activeIntegrations)) {
|
||||||
UserDefaults.standard.set(data, forKey: "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) }
|
let a = try? JSONDecoder().decode([String].self, from: d) { vercelProjectFilter = Set(a) }
|
||||||
if let d = ud.data(forKey: "n8nWorkflowFilter"),
|
if let d = ud.data(forKey: "n8nWorkflowFilter"),
|
||||||
let a = try? JSONDecoder().decode([String].self, from: d) { n8nWorkflowFilter = Set(a) }
|
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"),
|
if let d = ud.data(forKey: "activeIntegrations"),
|
||||||
let a = try? JSONDecoder().decode([String].self, from: d) { activeIntegrations = Set(a) }
|
let a = try? JSONDecoder().decode([String].self, from: d) { activeIntegrations = Set(a) }
|
||||||
if let v = ud.string(forKey: "mainPill"), !v.isEmpty,
|
if let v = ud.string(forKey: "mainPill"), !v.isEmpty,
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ struct IslandViewContent: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch view {
|
switch view {
|
||||||
case .overview: OverviewView(state: state)
|
// BroV home: two floors of flat app pills (Claude, ChatGPT, two free slots).
|
||||||
case .empty: EmptyStateView(state: state)
|
case .overview: AppGridView(state: state)
|
||||||
|
case .empty: AppGridView(state: state)
|
||||||
case .approval: ApprovalView(state: state)
|
case .approval: ApprovalView(state: state)
|
||||||
case .question: QuestionView(state: state)
|
case .question: QuestionView(state: state)
|
||||||
case .error: ErrorView(state: state)
|
case .error: ErrorView(state: state)
|
||||||
@@ -4001,8 +4002,9 @@ struct PillBadgeView: View {
|
|||||||
struct ColumnAgentsView: View {
|
struct ColumnAgentsView: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
|
||||||
|
// BroV: the VS Code container pill (integration_claude) stays internal, never shown.
|
||||||
var others: [AgentTask] {
|
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 {
|
var body: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user