Files
brov-macbook/NotchBuddy/Sources/App/IslandViewContent.swift
T

4747 lines
195 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - Dispatch view content by IslandView
struct IslandViewContent: View {
let view: IslandView
@ObservedObject var state: AppState
var body: some View {
switch view {
// 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)
case .finished: FinishedView(state: state)
case .confused: ConfusedView()
case .upload: UploadView(state: state)
case .uploading: UploadingView(state: state)
case .choose: ChooseView(state: state)
case .mail: MailView(state: state)
case .prompt: PromptView(state: state)
case .searching: SearchingView(state: state)
case .result: ResultView(state: state)
case .note: NoteView(state: state)
case .settings: SettingsIslandView(state: state)
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
case .wardrobe: WardrobeView(state: state)
case .terminal: TerminalScreen(state: state)
}
}
}
// MARK: - Overview
struct OverviewView: View {
@ObservedObject var state: AppState
@State private var showingN8nDetail = false
@State private var activeDiffId: Int? = nil
var agent: AgentTask? { state.focusTask }
var body: some View {
HStack(spacing: 10) {
// Left card: title row + ticker below + ↗ button overlay
ZStack(alignment: .topLeading) {
CardBackground(wash: nil)
// Title row + ticker stacked (or integration card)
if let agent = agent {
if agent.isIntegration {
IntegrationCardView(task: agent, showingDetail: $showingN8nDetail, onDiffTap: { diffIdx in
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx }
})
} else {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: agent.color))
.frame(width: 7, height: 7)
Text(agent.name)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
Text({ () -> String in
switch agent.source {
case .claudeCode: return "Claude Code"
case .agent: return "Агент"
case .n8n: return "n8n"
}
}())
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 2)
if agent.steps.count > 1 {
Text("\(min(agent.stepIndex + 1, agent.steps.count))/\(agent.steps.count)")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
.fixedSize()
}
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
TickerView(task: agent, onDiffTap: { diffIdx in
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx }
})
.frame(height: 44)
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
}
// Plan detail overlays on top of normal content (GitHub build, home view only)
#if !APPSTORE
if state.showingPlanDetail {
CardBackground(wash: nil)
ClaudePlanCardView(usage: state.claudePlanUsage)
.transition(.opacity)
}
#endif
// Diff overlay — replaces ticker when a diff step is tapped
if let diffId = activeDiffId,
let task = agent,
let diff = state.sessionDiffs[task.id]?.first(where: { $0.id == diffId }) {
CardBackground(wash: nil)
DiffCardView(diff: diff, onDismiss: { activeDiffId = nil })
.transition(.opacity)
}
// ↗ jump button — last in ZStack so it renders on top; hidden while any detail is open
#if !APPSTORE
let hideJumpButton = showingN8nDetail || state.showingPlanDetail || activeDiffId != nil
#else
let hideJumpButton = showingN8nDetail || activeDiffId != nil
#endif
if !hideJumpButton {
Button(action: { openAgentTarget(agent) }) {
Image(systemName: "arrow.up.right")
.font(.system(size: 8, weight: .medium))
.foregroundColor(Color(hex: "#5F646D"))
.frame(width: 16, height: 16)
.background(Color.white.opacity(0.07))
.clipShape(Circle())
}
.buttonStyle(.plain)
.padding(.top, 8)
.padding(.trailing, 10)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.frame(width: 322)
// Right card: agent pills
CardBackground(wash: nil) {
AgentPillsView(state: state)
}
}
.onChange(of: state.focusId) { _, new in
showingN8nDetail = false
activeDiffId = nil
#if !APPSTORE
withAnimation(.easeIn(duration: 0.16)) { state.showingPlanDetail = false }
#endif
if new == "integration_github" { GithubPoller.shared.refreshIfStale() }
}
#if !APPSTORE
.onChange(of: state.view) { _, v in
if v != .overview { state.showingPlanDetail = false; activeDiffId = nil }
}
#endif
.onChange(of: state.mode) { _, m in
#if !APPSTORE
if m != .expanded { state.showingPlanDetail = false; activeDiffId = nil }
#endif
if m == .expanded && state.focusId == "integration_github" {
GithubPoller.shared.refreshIfStale()
}
}
.onReceive(NotificationCenter.default.publisher(for: .islandToggleDiff)) { _ in
if let id = activeDiffId {
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = nil }
_ = id
} else if let task = state.focusTask,
let last = state.sessionDiffs[task.id]?.last {
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = last.id }
}
}
}
private func openAgentTarget(_ task: AgentTask?) {
guard let task else { return }
switch task.id {
case "integration_claude":
let vscodeBundleId = "com.microsoft.VSCode"
if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == vscodeBundleId }) {
app.activate(options: .activateIgnoringOtherApps)
} else {
NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Visual Studio Code.app"))
}
case "integration_resend":
NSWorkspace.shared.open(URL(string: "https://resend.com/emails")!)
case "integration_vercel":
NSWorkspace.shared.open(URL(string: "https://vercel.com/dashboard")!)
case "integration_github":
NSWorkspace.shared.open(URL(string: "https://github.com/pulls")!)
case "integration_n8n":
if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) {
NSWorkspace.shared.open(url)
}
case "integration_stripe":
NSWorkspace.shared.open(URL(string: "https://dashboard.stripe.com/payments")!)
case "integration_notion":
NSWorkspace.shared.open(URL(string: "https://notion.so")!)
case "integration_calcom":
NSWorkspace.shared.open(URL(string: "https://app.cal.com/bookings")!)
case "agent_cursor":
#if !APPSTORE
if let url = NSWorkspace.shared.urlForApplication(
withBundleIdentifier: "com.todesktop.230313mzl4w4u92") {
NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil)
}
#endif
case "agent_codex":
#if !APPSTORE
if let url = NSWorkspace.shared.urlForApplication(
withBundleIdentifier: "com.openai.codex") {
NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil)
}
#endif
case "agent_gemini", "agent_antigravity":
#if !APPSTORE
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2",
"net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
if let hit = terminalBundleIds.compactMap({ id in
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
}).first {
hit.activate(options: .activateIgnoringOtherApps)
}
#endif
case "ai_anthropic":
switchChatProvider(.anthropic)
case "ai_google":
switchChatProvider(.google)
case "ai_openai":
switchChatProvider(.openai)
case "ai_ollama":
switchChatProvider(.ollama)
case "ai_lmstudio":
switchChatProvider(.lmstudio)
case "integration_music":
#if !APPSTORE
MusicController.shared.openMusic()
#endif
default:
// Non-integration real tasks
if task.source == .n8n {
if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) {
NSWorkspace.shared.open(url)
}
} else {
#if !APPSTORE
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2",
"net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
if let hit = terminalBundleIds.compactMap({ id in
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
}).first {
hit.activate(options: .activateIgnoringOtherApps)
}
#endif
}
}
}
}
// MARK: - Empty
struct EmptyStateView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
CardBackground(wash: nil)
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
Text("Сейчас ничего не запущено.")
.font(.system(size: 15, weight: .semibold))
Text("Перетащи файл или окно — или спроси что угодно.")
.font(.system(size: 13))
.foregroundColor(Color(hex: "#9398A1"))
}
Spacer()
PrimaryButton("Спросить Claude") {
state.view = .prompt
}
}
.padding(.leading, 118)
.padding(.trailing, 18)
}
}
}
// MARK: - Approval
struct ApprovalView: View {
@ObservedObject var state: AppState
var approval: ApprovalInfo? { state.pendingApproval }
var body: some View {
let danger = approval?.danger ?? []
ZStack {
CardBackground(wash: danger.isEmpty ? .amber : .red)
VStack(alignment: .leading, spacing: 5) {
AgentWho(task: state.focusTask, label: "просит разрешение")
CodeBlock(text: approval?.command ?? approval?.tool ?? "…")
if !danger.isEmpty {
HStack(alignment: .top, spacing: 5) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10, weight: .bold))
Text("Опасно: " + danger.joined(separator: "; "))
.font(.system(size: 10.5, weight: .semibold))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(Color(hex: "#FF6B76"))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 6).fill(Color(hex: "#F4505E").opacity(0.14)))
}
HStack(spacing: 8) {
SecondaryButton("Запретить") {
HookServer.shared.sendApprovalDecision("deny")
}
PrimaryButton("Разрешить") {
HookServer.shared.sendApprovalDecision("allow")
}
// Codex rejects updatedPermissions, so "Always" is not offered.
// Never for a dangerous command: one click must not save a destructive rule.
if approval?.pillId != "agent_codex" && danger.isEmpty {
SecondaryButton("Всегда") {
HookServer.shared.sendApprovalDecision("always")
}
}
}
}
.padding(.leading, 116)
.padding(.trailing, 16)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Question
struct QuestionView: View {
@ObservedObject var state: AppState
@State private var questionIndex = 0
// Per-question selected labels (empty = none chosen yet)
@State private var selections: [[String]] = []
// Per-question custom "Other…" text
@State private var otherTexts: [String] = []
// Per-question "Other…" mode active
@State private var showOther: [Bool] = []
@FocusState private var otherFieldFocused: Bool
var question: AskQuestion? { state.pendingQuestion }
var body: some View {
ZStack {
CardBackground(wash: .cyan)
if let q = question, !q.questions.isEmpty {
let qi = min(questionIndex, q.questions.count - 1)
let item = q.questions[qi]
let isLast = qi == q.questions.count - 1
let isMulti = item.multiSelect
let curSel = qi < selections.count ? selections[qi] : []
let curOther = qi < showOther.count ? showOther[qi] : false
let curOtherText = qi < otherTexts.count ? otherTexts[qi] : ""
let canProceed = !curSel.isEmpty || (curOther && !curOtherText.isEmpty)
VStack(alignment: .leading, spacing: 4) {
// Header row: agent name + question counter + "Reply in terminal" link
HStack(spacing: 4) {
AgentWho(task: nil, label: "Claude Code спрашивает")
Spacer(minLength: 4)
if q.questions.count > 1 {
Text("\(qi + 1)/\(q.questions.count)")
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
}
Button("Ответить в терминале") { HookServer.shared.sendQuestionAsk() }
.buttonStyle(.plain)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
.underline()
}
// Optional short header label above question text
if !item.header.isEmpty {
Text(item.header)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
}
Text(item.question)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(2)
// Options (wrapping) or "Other…" compact inline row
if curOther {
HStack(spacing: 6) {
TextField("Твой ответ…", text: Binding(
get: { qi < otherTexts.count ? otherTexts[qi] : "" },
set: { v in if qi < otherTexts.count { otherTexts[qi] = v } }
))
.textFieldStyle(.plain)
.font(.system(size: 12))
.foregroundColor(Color(hex: "#F5F6F8"))
.focused($otherFieldFocused)
.onAppear { otherFieldFocused = true }
.onSubmit { commitOtherAndProceed(q: q, qi: qi, isLast: isLast) }
.onExitCommand { if qi < showOther.count { showOther[qi] = false } }
.padding(.horizontal, 8).padding(.vertical, 5)
.background(Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 8))
Button(isLast ? "Отправить" : "Далее") {
commitOtherAndProceed(q: q, qi: qi, isLast: isLast)
}
.buttonStyle(.plain)
.font(.system(size: 11, weight: .medium))
.foregroundColor(curOtherText.isEmpty ? Color(hex: "#6B7079") : Color(hex: "#F5F6F8"))
.padding(.horizontal, 8).padding(.vertical, 5)
.background(Color.white.opacity(curOtherText.isEmpty ? 0.05 : 0.15))
.clipShape(Capsule())
.disabled(curOtherText.isEmpty)
Button { if qi < showOther.count { showOther[qi] = false } } label: {
Text("✕").font(.system(size: 9))
}
.buttonStyle(.plain)
.foregroundColor(Color(hex: "#6B7079"))
}
} else {
ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
let isSelected = curSel.contains(opt.label)
if isMulti {
Button {
toggleSelection(qi: qi, label: opt.label)
} label: {
Text(opt.label)
.font(.system(size: 12, weight: .medium))
.padding(.horizontal, 8).padding(.vertical, 4)
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#C5C8CD"))
.clipShape(RoundedRectangle(cornerRadius: 7))
.overlay(RoundedRectangle(cornerRadius: 7).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
} else {
SecondaryButton(opt.label) {
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
}
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
}
}
// "Other…" implicit free-text option
SecondaryButton("Другое…") {
if qi < showOther.count { showOther[qi] = true }
}
}
}
// Send/Next — only for multi-select (and not while "Other…" field is open)
if isMulti && !curOther {
PrimaryButton(isLast ? "Отправить" : "Далее") {
proceedFromQuestion(q: q, qi: qi, isLast: isLast)
}
.disabled(!canProceed)
.opacity(canProceed ? 1 : 0.4)
}
}
.padding(.leading, 116)
.padding(.trailing, 16)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.onAppear { resetQuestionState() }
.onChange(of: state.pendingQuestion) { _, _ in resetQuestionState() }
.onDisappear { HookServer.shared.releaseQuestionFD() }
}
private func resetQuestionState() {
questionIndex = 0
let count = state.pendingQuestion?.questions.count ?? 0
selections = Array(repeating: [], count: count)
otherTexts = Array(repeating: "", count: count)
showOther = Array(repeating: false, count: count)
}
private func toggleSelection(qi: Int, label: String) {
guard qi < selections.count else { return }
if let i = selections[qi].firstIndex(of: label) {
selections[qi].remove(at: i)
} else {
selections[qi].append(label)
}
}
// Single-select: pick a label and immediately advance/send
private func selectAndProceed(q: AskQuestion, qi: Int, label: String, isLast: Bool) {
guard qi < selections.count else { return }
selections[qi] = [label]
if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } }
}
// Multi-select Send/Next button
private func proceedFromQuestion(q: AskQuestion, qi: Int, isLast: Bool) {
if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } }
}
// "Other…" confirm
private func commitOtherAndProceed(q: AskQuestion, qi: Int, isLast: Bool) {
let text = qi < otherTexts.count ? otherTexts[qi] : ""
guard !text.isEmpty else { return }
if qi < selections.count { selections[qi] = [text] }
if isLast {
sendAnswers(q: q)
} else {
if qi < showOther.count { showOther[qi] = false }
withAnimation { questionIndex = qi + 1 }
}
}
private func sendAnswers(q: AskQuestion) {
let answers = AskQuestion.buildAnswers(questions: q.questions, selections: selections)
HookServer.shared.sendQuestionAnswers(answers)
}
}
// MARK: - Error
struct ErrorView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
CardBackground(wash: .red)
VStack(alignment: .leading, spacing: 5) {
AgentWho(task: state.focusTask, label: "n8n")
Text("Сценарий остановлен.")
.font(.system(size: 15, weight: .semibold))
Text("Узел Gmail не ответил за 30 с. Повтори или открой n8n.")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#FF8D97"))
HStack(spacing: 8) {
PrimaryButton("Повторить") { /* retry */ }
SecondaryButton("Открыть в n8n") { /* open */ }
}
}
.padding(.leading, 116)
.padding(.trailing, 16)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Finished
struct FinishedView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
CardBackground(wash: .green)
VStack(alignment: .leading, spacing: 5) {
AgentWho(task: state.focusTask, label: "Claude Code закончил")
Text({
if let fl = state.focusTask?.finalLine { return fl }
if let s = state.focusTask?.steps.last(where: { !$0.isDiffStep }) { return s }
return "Сессия завершена"
}())
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.truncationMode(.tail)
HStack(spacing: 8) {
#if !APPSTORE
PrimaryButton("Открыть терминал") {
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", "net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
let activated = terminalBundleIds.compactMap { id in
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
}.first.map { $0.activate(options: .activateIgnoringOtherApps) }
if activated == nil {
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
}
NotificationCenter.default.post(name: .islandCollapse, object: nil)
}
#endif
SecondaryButton("ОК") {
NotificationCenter.default.post(name: .islandCollapse, object: nil)
}
}
}
.padding(.leading, 116)
.padding(.trailing, 16)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Diff Card
struct DiffCardView: View {
let diff: FileDiff
let onDismiss: () -> Void
private var allLines: [DiffLine] { diff.hunks.flatMap { $0.lines } }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Button(action: onDismiss) {
HStack(spacing: 3) {
Image(systemName: "chevron.left")
.font(.system(size: 8, weight: .medium))
Text(diff.name)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundColor(Color(hex: "#F5F6F8"))
}
.buttonStyle(.plain)
Spacer(minLength: 2)
if diff.added > 0 {
Text("+\(diff.added)")
.font(.system(size: 10, weight: .medium).monospaced())
.foregroundColor(Color(hex: "#22C55E"))
}
if diff.removed > 0 {
Text("−\(diff.removed)")
.font(.system(size: 10, weight: .medium).monospaced())
.foregroundColor(Color(hex: "#F4505E"))
}
Button(action: { openInEditor(diff) }) {
Image(systemName: "arrow.up.right")
.font(.system(size: 8, weight: .medium))
.foregroundColor(Color(hex: "#5F646D"))
.frame(width: 14, height: 14)
.background(Color.white.opacity(0.07))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.top, 6)
.padding(.bottom, 3)
// Content
if diff.tooLarge {
Text("Слишком большой diff")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#6B7079"))
} else if allLines.isEmpty {
Text("Нет изменений")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#6B7079"))
} else {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(allLines.enumerated()), id: \.offset) { _, line in
DiffLineRowView(line: line)
}
}
}
}
}
.padding(.leading, 108)
.padding(.trailing, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onExitCommand { onDismiss() }
}
private func openInEditor(_ diff: FileDiff) {
let path = diff.path
#if !APPSTORE
let codePaths = ["/opt/homebrew/bin/code", "/usr/local/bin/code", "/usr/bin/code",
"\(NSHomeDirectory())/.nvm/current/bin/code"]
if let codePath = codePaths.first(where: { FileManager.default.fileExists(atPath: $0) }) {
let p = Process()
p.executableURL = URL(fileURLWithPath: codePath)
// Write: open at line 1; Edit/MultiEdit: open file without line number
p.arguments = diff.isNewFile ? ["-g", "\(path):1"] : [path]
try? p.run()
return
}
#endif
NSWorkspace.shared.open(URL(fileURLWithPath: path))
}
}
struct DiffLineRowView: View {
let line: DiffLine
private var bgColor: Color {
switch line.kind {
case .added: return Color(hex: "#22C55E").opacity(0.12)
case .removed: return Color(hex: "#F4505E").opacity(0.12)
case .context: return Color.clear
}
}
private var fgColor: Color {
switch line.kind {
case .added: return Color(hex: "#86EFAC")
case .removed: return Color(hex: "#FCA5A5")
case .context: return Color(hex: "#6B7079")
}
}
private var symbol: String {
switch line.kind {
case .added: return "+"
case .removed: return "−"
case .context: return " "
}
}
var body: some View {
HStack(spacing: 0) {
Text(symbol)
.font(.system(size: 10.5).monospaced())
.foregroundColor(line.kind == .added ? Color(hex: "#22C55E") :
line.kind == .removed ? Color(hex: "#F4505E") :
Color(hex: "#454850"))
.frame(width: 12, alignment: .leading)
Text(line.text)
.font(.system(size: 10.5).monospaced())
.foregroundColor(fgColor)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(bgColor)
.frame(maxWidth: .infinity)
}
}
// MARK: - Confused
struct ConfusedView: View {
var body: some View {
ZStack {
CardBackground(wash: .pink)
VStack(alignment: .leading, spacing: 5) {
Text("Слишком много тычков разом.").font(.system(size: 15, weight: .semibold))
Text("Дай секунду — вернусь к работе через три секунды.")
.font(.system(size: 13)).foregroundColor(Color(hex: "#9398A1"))
}
.padding(.leading, 128)
.padding(.trailing, 18)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Upload (drop zone)
struct UploadView: View {
@ObservedObject var state: AppState
@State private var dashPhase: CGFloat = 0
@State private var breathAngle: Double = 0
// Timer only runs while this is the active tab — killed on deactivation
@State private var animTimer: Timer? = nil
private var borderOpacity: Double {
let breathe = 0.11 + 0.04 * (sin(breathAngle) * 0.5 + 0.5)
return state.fileDragOver ? 0.65 : breathe
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "#0E0F11"))
RoundedRectangle(cornerRadius: 20)
.stroke(
state.fileDragOver
? Color(hex: "#22C55E").opacity(borderOpacity)
: Color.white.opacity(borderOpacity),
style: StrokeStyle(lineWidth: 1.5, dash: [6, 5], dashPhase: dashPhase)
)
RoundedRectangle(cornerRadius: 20)
.fill(RadialGradient(
colors: [Color(hex: "#22C55E").opacity(state.fileDragOver ? 0.13 : 0), Color.clear],
center: .bottom, startRadius: 0, endRadius: 200
))
VStack(alignment: .leading, spacing: 8) {
Text("Перетащи файлы сюда")
.font(.system(size: 13, weight: .medium))
.foregroundColor(state.fileDragOver ? Color(hex: "#34D399") : Color(hex: "#D5D7DB"))
HStack(spacing: 6) {
ForEach(["PDF", "Фото", "Код", "Документы"], id: \.self) { label in
Text(label)
.font(.system(size: 11))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.white.opacity(0.07))
.foregroundColor(Color(hex: "#B9BDC4"))
.clipShape(Capsule())
}
}
}
.padding(.leading, 196)
.frame(maxWidth: .infinity, alignment: .leading)
}
.onChange(of: state.view) { _, newView in
newView == .upload ? startTimer() : stopTimer()
}
.onAppear {
if state.view == .upload { startTimer() }
}
.onDisappear { stopTimer() }
}
private func startTimer() {
guard animTimer == nil else { return }
// 20 fps — smooth enough for slow dash, 3× lighter than 60fps
animTimer = Timer.scheduledTimer(withTimeInterval: 1.0/20.0, repeats: true) { _ in
dashPhase += 1.0 // 20 pt/s march
breathAngle += 0.9 / 20.0 // advance sin phase at 0.9 rad/s
}
}
private func stopTimer() {
animTimer?.invalidate()
animTimer = nil
}
}
// MARK: - Uploading
struct UploadingView: View {
@ObservedObject var state: AppState
// Bar geometry in content coords (content has 10pt H padding each side).
// Island bar: left=36, right=562 (640-78), width=526.
// Content bar: left=26, width=526.
// barTop=58 → island y = content_start(42)+58 = 100; bot cy=103 (center = barTop+3).
private let barLeft: CGFloat = 26
private let barWidth: CGFloat = 526
private let barTop: CGFloat = 58
var body: some View {
// TimelineView fires at display refresh rate — progress derived from elapsed wall time,
// not from @Published uploadProgress (which only flips to 1.0 at completion).
TimelineView(.animation) { tl in
let elapsed: Double = {
guard let start = state.uploadStartTime else { return 0 }
return tl.date.timeIntervalSince(start)
}()
let t = min(1.0, max(0, elapsed / state.uploadDuration))
let progress = CGFloat(t * (2 - t)) // ease-out quad
let fillWidth = max(0, barWidth * progress)
let isDone = state.uploadProgress >= 0.999 // only true after handle() sets it
ZStack(alignment: .topLeading) {
// Background: dark base
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "#141518"))
// Permanent green radial wash — brighter at completion
RoundedRectangle(cornerRadius: 20)
.fill(RadialGradient(
colors: [Color(hex: "#34D399").opacity(isDone ? 0.28 : 0.14), Color.clear],
center: UnitPoint(x: 0.5, y: 1.4),
startRadius: 0,
endRadius: 260
))
// Bar track
RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.09))
.frame(width: barWidth, height: 6)
.offset(x: barLeft, y: barTop)
// Bar fill
RoundedRectangle(cornerRadius: 3)
.fill(LinearGradient(
colors: [Color(hex: "#1FA87A"), Color(hex: "#34D399")],
startPoint: .leading, endPoint: .trailing
))
.frame(width: fillWidth, height: 6)
.offset(x: barLeft, y: barTop)
// Glow trail behind dot leading edge
if progress > 0.01 {
Ellipse()
.fill(Color(hex: "#6EE7B7").opacity(0.45))
.frame(width: 28, height: 12)
.blur(radius: 5)
.offset(x: barLeft + fillWidth - 14, y: barTop - 3)
}
// Text row — filename + % (above bar)
HStack(spacing: 0) {
if isDone {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#34D399"))
Text(" \(state.droppedFile?.name ?? "Файл")")
.font(.system(size: 12.5, weight: .semibold))
.foregroundColor(Color(hex: "#34D399"))
.lineLimit(1).truncationMode(.middle)
} else {
Text("Загружаю \(state.droppedFile?.name ?? "файл")")
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#A9ADB5"))
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("\(Int(progress * 100)) %")
.font(.system(size: 12.5, weight: .medium))
.foregroundColor(Color(hex: "#A9ADB5"))
.monospacedDigit()
}
}
.frame(width: barWidth)
.offset(x: barLeft, y: barTop - 22)
// Subtle top border (same as CardBackground)
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.035), lineWidth: 1)
}
}
}
}
// MARK: - Choose
struct ChooseView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 8) {
let fileName = state.droppedFile?.name ?? "Файл"
(Text(fileName).font(.system(size: 14, weight: .semibold)) + Text(" готов.").font(.system(size: 14, weight: .semibold)))
Text("Что с ним сделать?").font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1"))
HStack(spacing: 8) {
PrimaryButton("Задать вопрос") { state.view = .prompt }
SecondaryButton("Отправить почтой") { state.view = .mail }
SecondaryButton("На полку") { state.shelveLastDrop() }
}
}
.padding(.leading, 98)
.padding(.trailing, 18)
}
}
}
// MARK: - Mail
struct MailView: View {
@ObservedObject var state: AppState
@State private var to: String = ""
@State private var subject: String = ""
@State private var bodyText: String = ""
@State private var statusMsg: String = ""
@State private var isSending = false
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("Новое письмо").font(.system(size: 12, weight: .semibold))
if let name = state.droppedFile?.name {
Text("with").font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
Text(name).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1).truncationMode(.middle)
}
}
MailField(label: "Кому", placeholder: "address@example.com", text: $to)
MailField(label: "Тема", placeholder: state.droppedFile?.name ?? "Тема", text: $subject)
// Body — TextEditor scrolls internally when text overflows
TextEditor(text: $bodyText)
.scrollContentBackground(.hidden)
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#F5F6F8"))
.frame(height: 44)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
if !statusMsg.isEmpty {
Text(statusMsg).font(.system(size: 11)).foregroundColor(Color(hex: "#FF8D97"))
}
HStack(spacing: 8) {
PrimaryButton(isSending ? "Отправка…" : "Отправить") {
guard !isSending else { return }
sendMail()
}
SecondaryButton("Отмена") { state.view = .choose }
}
}
.padding(.leading, 92)
.padding(.trailing, 18)
.padding(.vertical, 8)
}
.onAppear { subject = state.droppedFile?.name ?? "" }
}
private func sendMail() {
guard !to.isEmpty else { statusMsg = "Не указан получатель."; return }
let subj = subject.isEmpty ? (state.droppedFile?.name ?? "Файл") : subject
// Prefer Resend if API key + sender address are configured
let apiKey = KeychainStore.shared.get("resend-api-key")
let fromAddr = KeychainStore.shared.get("resend-from")
if let apiKey, let fromAddr {
isSending = true
statusMsg = ""
let recipient = to
let msgBody = bodyText
let fileURL = state.droppedFile?.url
Task {
let ok = await sendViaResend(apiKey: apiKey, from: fromAddr,
to: recipient, subject: subj,
body: msgBody, fileURL: fileURL)
await MainActor.run {
isSending = false
if ok { onSuccess(recipient: recipient) }
else { statusMsg = "Ошибка Resend — проверь API-ключ и отправителя." }
}
}
} else if apiKey != nil && fromAddr == nil {
// API key set but no sender — guide user instead of silent fallback
statusMsg = "Укажи адрес отправителя в настройках."
} else {
// No Resend — fallback to Mail
sendViaAppleMail(to: to, subject: subj)
}
}
private func sendViaResend(apiKey: String, from: String, to: String,
subject: String, body: String, fileURL: URL?) async -> Bool {
guard let url = URL(string: "https://api.resend.com/emails") else { return false }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var payload: [String: Any] = [
"from": from,
"to": [to],
"subject": subject,
"text": body.isEmpty ? " " : body
]
if let fileURL, let data = try? Data(contentsOf: fileURL) {
payload["attachments"] = [[
"filename": fileURL.lastPathComponent,
"content": data.base64EncodedString()
]]
}
guard let httpBody = try? JSONSerialization.data(withJSONObject: payload) else { return false }
request.httpBody = httpBody
guard let (_, response) = try? await URLSession.shared.data(for: request) else { return false }
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 200 || code == 201 { return true }
print("[Resend] HTTP \(code)")
return false
}
private func sendViaAppleMail(to: String, subject: String) {
#if APPSTORE
// App Store: no AppleScript — use NSSharingService to compose (user sends manually)
guard let service = NSSharingService(named: .composeEmail) else {
statusMsg = "Mail недоступен."
return
}
var items: [Any] = [bodyText.isEmpty ? " " : bodyText]
if let url = state.droppedFile?.url,
FileManager.default.fileExists(atPath: url.path) {
items.append(url)
}
service.recipients = [to]
service.subject = subject
service.perform(withItems: items)
onSuccess(recipient: to)
#else
func asEscape(_ s: String) -> String {
s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
let bodyLines = bodyText.isEmpty ? [""] : bodyText.components(separatedBy: "\n")
let bodyExpr = bodyLines.map { "\"\(asEscape($0))\"" }.joined(separator: " & linefeed & ")
+ " & return & return"
let attachBlock: String
if let url = state.droppedFile?.url,
FileManager.default.fileExists(atPath: url.path) {
let escapedPath = asEscape(url.path)
attachBlock = "make new attachment with properties {file name:(POSIX file \"\(escapedPath)\")} at after the last paragraph of content"
} else {
attachBlock = ""
}
let script = """
tell application "Mail"
set m to make new outgoing message with properties {subject:"\(asEscape(subject))", visible:false}
set content of m to \(bodyExpr)
tell m
make new to recipient at end of to recipients with properties {address:"\(asEscape(to))"}
\(attachBlock)
end tell
delay 1
send m
end tell
"""
var err: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&err)
if err == nil { onSuccess(recipient: to) }
else { statusMsg = "Ошибка Mail: \(err?["NSAppleScriptErrorMessage"] as? String ?? "неизвестно")" }
#endif
}
private func onSuccess(recipient: String) {
SoundEngine.shared.play("send")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.wink)
state.noteMessage = "Письмо отправлено: \(recipient)."
state.view = .note
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
NotificationCenter.default.post(name: .islandCollapse, object: nil)
}
}
}
// MARK: - Prompt (chat)
struct PromptView: View {
@ObservedObject var state: AppState
@State private var text: String = ""
@FocusState private var focused: Bool
@State private var showModelPicker = false
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: .indigo)
VStack(alignment: .leading, spacing: 6) {
if let ctx = state.promptContext {
ContextChip(context: ctx).padding(.top, 4)
}
if !state.chatHistory.isEmpty {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 6) {
ForEach(state.chatHistory) { msg in
ChatBubble(message: msg).id(msg.id)
}
if state.stateOverride != nil {
HStack { TypingDotsView(); Spacer(minLength: 32) }
.id("typing")
}
}
.padding(.vertical, 2)
}
.onChange(of: state.chatHistory) { _, _ in
if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
.onChange(of: state.stateOverride) { _, v in
if v != nil {
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
} else if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
.onAppear {
if let last = state.chatHistory.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.frame(maxHeight: .infinity)
} else {
Spacer()
}
HStack(spacing: 0) {
Spacer()
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
showModelPicker.toggle()
}
} label: {
HStack(spacing: 5) {
Circle()
.fill(Color(hex: state.chatProvider.accentHex))
.frame(width: 6, height: 6)
Text(state.activeChatModel)
.font(.system(size: 10.5, weight: .medium))
.foregroundColor(Color(hex: "#7B8089"))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.foregroundColor(Color(hex: "#5C6370"))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.white.opacity(0.06))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.popover(isPresented: $showModelPicker, arrowEdge: .bottom) {
ModelPickerView(state: state, isPresented: $showModelPicker)
.frame(width: 300)
}
}
.padding(.horizontal, 10)
HStack(spacing: 8) {
TextField(state.chatHistory.isEmpty ? "Спроси что угодно…" : "Продолжить…", text: $text)
.textFieldStyle(.plain)
.font(.system(size: 13))
.focused($focused)
.onSubmit { sendMessage() }
Button(action: sendMessage) {
Image(systemName: "arrow.up")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color(hex: "#0B0C0E"))
}
.buttonStyle(SendButtonStyle())
.disabled(text.isEmpty)
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 12))
.simultaneousGesture(TapGesture().onEnded { focused = true })
}
.padding(.leading, 84)
.padding(.trailing, 16)
.padding(.top, 12)
.padding(.bottom, 14)
}
.padding(.bottom, 10)
.onAppear { focused = true }
.onChange(of: state.view) { _, view in
if view == .prompt {
state.fetchModelsIfNeeded(for: state.chatProvider)
}
}
.onChange(of: state.chatProvider) { _, provider in
if state.view == .prompt {
state.fetchModelsIfNeeded(for: provider)
}
}
.onReceive(NotificationCenter.default.publisher(for: .islandSendMessage)) { _ in
guard state.view == .prompt else { return }
sendMessage()
}
.onReceive(NotificationCenter.default.publisher(for: .islandNewConversation)) { _ in
guard state.view == .prompt else { return }
text = ""
state.chatHistory = []
ClaudeService.shared.clearConversation()
ChatTabs.shared.cli(for: state.activeTabId).newConversation()
focused = true
}
}
private func sendMessage() {
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return }
text = ""
focused = false
state.chatHistory.append(ChatMessage(role: .user, content: query))
state.stateOverride = .thinking
Task {
await ClaudeService.shared.chat(query: query, context: state.promptContext, state: state)
await MainActor.run { focused = true }
}
}
}
// MARK: - Model / provider picker
/// Wrapping horizontal flow layout — used by ModelPickerView and QuestionView.
struct ChipFlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let r = rows(maxW: proposal.replacingUnspecifiedDimensions().width, subviews: subviews)
return r.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let r = rows(maxW: bounds.width, subviews: subviews)
for (idx, pt) in r.placements.enumerated() {
subviews[idx].place(at: CGPoint(x: bounds.minX + pt.x, y: bounds.minY + pt.y), proposal: .unspecified)
}
}
private func rows(maxW: CGFloat, subviews: Subviews) -> (size: CGSize, placements: [(x: CGFloat, y: CGFloat)]) {
var x: CGFloat = 0, y: CGFloat = 0, lineH: CGFloat = 0, maxX: CGFloat = 0
var pts: [(x: CGFloat, y: CGFloat)] = []
for sv in subviews {
let sz = sv.sizeThatFits(.unspecified)
if x + sz.width > maxW, x > 0 { x = 0; y += lineH + spacing; lineH = 0 }
pts.append((x: x, y: y))
x += sz.width + spacing
lineH = max(lineH, sz.height)
maxX = max(maxX, x - spacing)
}
return (CGSize(width: max(maxX, 0), height: y + lineH), pts)
}
}
struct ModelPickerView: View {
@ObservedObject var state: AppState
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Provider chips — wrap; hide local providers when not connected and not already active
let visibleProviders = ChatProvider.allCases.filter { p in
if p == .ollama { return !AppState.shared.ollamaServerURL.isEmpty || state.chatProvider == .ollama }
if p == .lmstudio { return !AppState.shared.lmstudioServerURL.isEmpty || state.chatProvider == .lmstudio }
return true
}
ChipFlowLayout(spacing: 6) {
ForEach(visibleProviders, id: \.self) { provider in
Button {
guard provider != state.chatProvider else { return }
withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
state.chatProvider = provider
}
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
SoundEngine.shared.play("pop")
} label: {
HStack(spacing: 5) {
Circle()
.fill(Color(hex: provider.accentHex))
.frame(width: 7, height: 7)
Text(provider.displayName)
.font(.system(size: 12, weight: state.chatProvider == provider ? .semibold : .regular))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(state.chatProvider == provider
? Color(hex: provider.accentHex).opacity(0.18)
: Color.white.opacity(0.06))
.overlay(Capsule().stroke(
state.chatProvider == provider
? Color(hex: provider.accentHex).opacity(0.5)
: Color.white.opacity(0.1),
lineWidth: 1))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
Divider().opacity(0.2)
// Model list for current provider — fetched dynamically
modelListView
.frame(height: 260, alignment: .top)
}
.padding(14)
.background(Color(hex: "#16171B"))
.onAppear {
// Force-refresh local providers every time the picker opens
if state.chatProvider.isLocal {
state.fetchedProviderModels[state.chatProvider] = nil
state.providerModelFetchError[state.chatProvider] = nil
}
state.fetchModelsIfNeeded(for: state.chatProvider)
}
.onChange(of: state.chatProvider) { _, provider in
if provider.isLocal {
state.fetchedProviderModels[provider] = nil
state.providerModelFetchError[provider] = nil
}
state.fetchModelsIfNeeded(for: provider)
}
}
@ViewBuilder
private var modelListView: some View {
if state.loadingProviderModels.contains(state.chatProvider) {
HStack(spacing: 8) {
ProgressView().scaleEffect(0.7)
Text("Загрузка моделей…")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#8A8F98"))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
} else if let error = state.providerModelFetchError[state.chatProvider] {
Text(error)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8A8F98"))
.padding(.vertical, 4)
} else if let models = state.fetchedProviderModels[state.chatProvider] {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(models, id: \.id) { model in
Button {
switch state.chatProvider {
case .claudeCode: state.claudeCodeModel = model.id
case .anthropic: state.claudeModel = model.id
case .google: state.googleChatModel = model.id
case .openai: state.openAIChatModel = model.id
case .ollama: state.ollamaChatModel = model.id
case .lmstudio: state.lmstudioChatModel = model.id
}
isPresented = false
SoundEngine.shared.play("blip")
} label: {
HStack {
Text(model.label)
.font(.system(size: 12))
.foregroundColor(state.activeChatModel == model.id
? Color(hex: state.chatProvider.accentHex)
: Color(hex: "#C8CDD4"))
Spacer()
if state.activeChatModel == model.id {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: state.chatProvider.accentHex))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(state.activeChatModel == model.id
? Color(hex: state.chatProvider.accentHex).opacity(0.1)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
}
}
}
}
struct ChatBubble: View {
let message: ChatMessage
var body: some View {
if !message.content.isEmpty {
HStack(alignment: .top) {
if message.role == .user {
Spacer(minLength: 32)
Text(message.content)
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#F1F2F4"))
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.white.opacity(0.13))
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
ChatMarkdownView(markdown: message.content)
Spacer(minLength: 8)
}
}
}
}
}
struct TypingDotsView: View {
@State private var phase = false
var body: some View {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color(hex: "#6B7079"))
.frame(width: 5, height: 5)
.scaleEffect(phase ? 1.2 : 0.6)
.animation(
.easeInOut(duration: 0.45).repeatForever().delay(Double(i) * 0.14),
value: phase
)
}
}
.padding(.horizontal, 2).padding(.vertical, 4)
.onAppear { phase = true }
}
}
// MARK: - Searching
struct SearchingView: View {
@ObservedObject var state: AppState
var label: String {
switch state.promptContext {
case .window(_, let title, _): return "Claude читает \(title)…"
case .file(let name, _): return "Claude читает \(name)…"
case nil: return "Claude ищет…"
}
}
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: .indigo)
VStack(alignment: .leading, spacing: 8) {
if let ctx = state.promptContext {
ContextChip(context: ctx)
}
ShimmeringText(label)
.font(.system(size: 13.5))
}
.padding(.leading, 84)
.padding(.trailing, 16)
}
}
}
// MARK: - Result
struct ResultView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: .green)
if let result = state.searchResult {
VStack(alignment: .leading, spacing: 7) {
Text(result.title)
.font(.system(size: 15, weight: .semibold))
VStack(spacing: 4) {
ForEach(result.items.prefix(3), id: \.label) { item in
HStack {
Text(item.label).font(.system(size: 12.5, weight: .semibold))
Spacer()
Text(item.detail).font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1"))
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
if let note = result.note {
Text(note).font(.system(size: 11)).foregroundColor(Color(hex: "#6E737C"))
}
HStack(spacing: 8) {
// The URL comes from the model, which may have read attacker-controlled
// files or pages: only plain web links may leave the app.
let openURL = safeWebURL(result.items.first?.url)
PrimaryButton("Открыть") {
if let openURL { NSWorkspace.shared.open(openURL) }
}
.disabled(openURL == nil)
.help(openURL?.absoluteString ?? "")
SecondaryButton("Копировать") {
let text = result.items.map { "\($0.label): \($0.detail)" }.joined(separator: "\n")
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
SecondaryButton("Закрыть") { state.view = state.tasks.isEmpty ? .empty : .overview }
}
}
.padding(.leading, 84)
.padding(.trailing, 16)
}
}
}
}
// MARK: - Note (short message, auto-closes)
struct NoteView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 4) {
Text(state.noteMessage ?? "")
.font(.system(size: 15, weight: .semibold))
}
.padding(.leading, 98)
}
}
}
// MARK: - Integration card (overview left card when an integration pill is focused)
struct IntegrationCardView: View {
let task: AgentTask
@Binding var showingDetail: Bool
var onDiffTap: ((Int) -> Void)? = nil
@ObservedObject private var appState = AppState.shared
@State private var githubDetailSection: GitHubDetailSection = .myPRs
private var isConfigured: Bool {
switch task.id {
case "integration_claude":
#if APPSTORE
// Sandboxed: can't read ~/.claude directly — check install flag set by HookServer
return UserDefaults.standard.bool(forKey: "coucouHooksInstalled")
#else
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let hooks = json["hooks"] as? [String: Any],
let ss = hooks["SessionStart"] as? [[String: Any]] else { return false }
return ss.contains { ($0["hooks"] as? [[String: Any]])?.contains {
let cmd = $0["command"] as? String
return cmd?.contains("NotchBuddy") == true || cmd?.contains("coucou") == true
} ?? false }
#endif
case "agent_gemini":
#if !APPSTORE
return HookServer.geminiHooksInstalled()
#else
return false
#endif
case "agent_antigravity":
#if !APPSTORE
return HookServer.agyHooksInstalled()
#else
return false
#endif
case "agent_cursor", "agent_codex":
return false // coming soon
case "integration_music":
#if !APPSTORE
return true // Apple Music is always installed on macOS
#else
return false
#endif
case "ai_anthropic": return KeychainStore.shared.get("anthropic-api-key") != nil
case "ai_google": return KeychainStore.shared.get("google-api-key") != nil
case "ai_openai": return KeychainStore.shared.get("openai-api-key") != nil
case "ai_ollama": return !AppState.shared.ollamaServerURL.isEmpty
case "ai_lmstudio": return !AppState.shared.lmstudioServerURL.isEmpty
case "integration_resend": return KeychainStore.shared.get("resend-api-key") != nil
case "integration_n8n": return KeychainStore.shared.get("n8n-api-key") != nil
case "integration_vercel": return KeychainStore.shared.get("vercel-token") != nil
case "integration_github": return KeychainStore.shared.get("github-token") != nil
case "integration_stripe": return KeychainStore.shared.get("stripe-api-key") != nil
case "integration_notion": return KeychainStore.shared.get("notion-api-key") != nil
case "integration_calcom": return KeychainStore.shared.get("calcom-api-key") != nil
default: return false
}
}
private var openURL: URL? {
switch task.id {
case "integration_claude": return nil // uses terminal button below
case "integration_resend": return URL(string: "https://resend.com/emails")
case "integration_n8n":
if let s = KeychainStore.shared.get("n8n-url") { return URL(string: s) }
return nil
case "integration_vercel": return URL(string: "https://vercel.com/dashboard")
case "integration_github": return URL(string: "https://github.com")
case "integration_stripe": return URL(string: "https://dashboard.stripe.com/payments")
case "integration_notion": return URL(string: "https://notion.so")
case "integration_calcom": return URL(string: "https://app.cal.com/bookings")
default: return nil
}
}
// Workspace/agent pill with active session: show ticker layout
private var agentSessionActive: Bool {
guard let def = PillCatalog.definition(for: task.id) else { return false }
guard def.category == .workspace || def.category == .agent else { return false }
return task.state != .idle || !task.steps.isEmpty
}
// n8n with a finished execution: show result row instead of "Open n8n" button
private var n8nHasActivity: Bool {
task.id == "integration_n8n" && !task.steps.isEmpty &&
(task.state == .finished || task.state == .error)
}
// Vercel with recent deployments
private var vercelHasActivity: Bool {
task.id == "integration_vercel" && !appState.vercelDeployments.isEmpty
}
// Resend with recent emails
private var resendHasData: Bool {
task.id == "integration_resend" && !appState.resendEmails.isEmpty
}
// GitHub with stats or pulse loaded
private var githubHasData: Bool {
task.id == "integration_github" && (appState.githubPulse != nil || appState.githubStats != nil)
}
// GitHub with pulse loaded (richer card)
private var githubHasPulse: Bool {
task.id == "integration_github" && appState.githubPulse != nil
}
// Stripe: show card as soon as first poll completes (balance OR payments)
private var stripeHasData: Bool {
task.id == "integration_stripe" && appState.stripeLoaded
}
// Cal.com: show calendar as soon as first poll completes
private var calcomHasData: Bool {
task.id == "integration_calcom" && appState.calcomLoaded
}
// Notion: show pages as soon as first poll completes
private var notionHasData: Bool {
task.id == "integration_notion" && appState.notionLoaded
}
// Apple Music: show card when a track is loaded (playing or paused) or automation is denied
private var musicIsActive: Bool {
#if !APPSTORE
guard task.id == "integration_music" else { return false }
if appState.musicAutomationDenied { return true }
return MusicController.shared.trackTitle != nil
#else
return false
#endif
}
private var statusDot: Color {
#if !APPSTORE
if task.id == "integration_music" {
if appState.musicAutomationDenied { return Color(hex: "#F4505E") }
return appState.musicPlaying ? Color(hex: "#FA2D48") : Color(hex: "#22C55E")
}
#endif
if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") }
let svcErr = task.id == "integration_stripe" ? appState.stripeError
: task.id == "integration_calcom" ? appState.calcomError
: nil
if svcErr != nil { return Color(hex: "#F4505E") }
return isConfigured ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
}
private var statusLabel: String {
#if !APPSTORE
if task.id == "integration_music" {
if appState.musicAutomationDenied { return "Автоматизация запрещена" }
if appState.musicPlaying { return "Играет · \(MusicController.shared.trackTitle ?? "Неизвестно")" }
return "Не играет"
}
#endif
if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Скоро" }
let svcErr = task.id == "integration_stripe" ? appState.stripeError
: task.id == "integration_calcom" ? appState.calcomError
: nil
if let err = svcErr { return err }
let isHooks = task.id == "agent_gemini" || task.id == "agent_antigravity"
let isAI = ChatProvider(pillID: task.id) != nil
if isConfigured {
if isHooks { return "Хуки установлены" }
if isAI {
let provider = ChatProvider(pillID: task.id)!
if provider.isLocal {
let model = provider == .ollama ? appState.ollamaChatModel : appState.lmstudioChatModel
return "Подключено · \(model)"
}
let model: String
switch task.id {
case "ai_anthropic": model = appState.claudeModel
case "ai_google": model = appState.googleChatModel
case "ai_openai": model = appState.openAIChatModel
default: model = ""
}
return "Ключ задан · \(model)"
}
return "Подключено · загрузка…"
} else {
if isHooks { return "Хуки не установлены" }
if isAI {
let provider = ChatProvider(pillID: task.id)!
return provider.isLocal ? "Не подключено" : "Ключ не задан"
}
return "Ключ не задан"
}
}
var body: some View {
if showingDetail && n8nHasActivity {
N8nDetailView(task: task) {
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 }
})
.transition(.opacity)
} else if resendHasData {
ResendCardView(emails: appState.resendEmails, total: appState.resendTotal)
.transition(.opacity)
} else if showingDetail && githubHasPulse {
GitHubDetailView(
section: githubDetailSection,
pulse: appState.githubPulse!,
activity: appState.githubActivity,
stats: appState.githubStats,
onBack: {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false }
}
)
.transition(.opacity)
} else if githubHasPulse {
GitHubPulseCardView(
pulse: appState.githubPulse!,
stats: appState.githubStats,
activity: appState.githubActivity,
onTapSection: { section in
githubDetailSection = section
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true }
}
)
.transition(.opacity)
} else if githubHasData {
GitHubStatsCardView(stats: appState.githubStats!)
.transition(.opacity)
} else if stripeHasData {
StripeCardView()
.transition(.opacity)
} else if calcomHasData {
CalcomCardView()
.transition(.opacity)
} else if notionHasData {
NotionCardView()
.transition(.opacity)
} else if musicIsActive {
#if !APPSTORE
MusicCardView()
.transition(.opacity)
#endif
} else if agentSessionActive {
// Active session view — reuse overview layout
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: task.color))
.frame(width: 7, height: 7)
Text(task.name)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(1).truncationMode(.tail)
.layoutPriority(1)
Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Агент")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1).truncationMode(.tail)
Spacer(minLength: 2)
if task.steps.count > 1 {
Text("\(min(task.stepIndex + 1, task.steps.count))/\(task.steps.count)")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
.fixedSize()
}
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
TickerView(task: task, onDiffTap: onDiffTap)
.frame(height: 44)
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
} else {
// Idle / not connected view — slides in from left when returning from detail
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: task.color))
.frame(width: 7, height: 7)
Text(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 ?? "Интеграция")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
Spacer(minLength: 2)
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
HStack(spacing: 5) {
Circle().fill(statusDot).frame(width: 5, height: 5)
Text(statusLabel)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
}
.padding(.leading, 108)
.padding(.top, 2)
HStack(spacing: 8) {
if task.id == "integration_claude" {
Button("Открыть Visual Studio Code") { openVSCode() }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.7))
.buttonStyle(.plain)
} else if task.id == "agent_cursor" {
#if !APPSTORE
if let url = NSWorkspace.shared.urlForApplication(
withBundleIdentifier: "com.todesktop.230313mzl4w4u92") {
Button("Открыть Cursor") {
NSWorkspace.shared.openApplication(at: url, configuration: .init(),
completionHandler: nil)
}
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.85))
.buttonStyle(.plain)
}
#endif
} else if task.id == "agent_codex" {
#if !APPSTORE
if let url = NSWorkspace.shared.urlForApplication(
withBundleIdentifier: "com.openai.codex") {
Button("Открыть Codex") {
NSWorkspace.shared.openApplication(at: url, configuration: .init(),
completionHandler: nil)
}
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.85))
.buttonStyle(.plain)
}
#endif
} else if let provider = ChatProvider(pillID: task.id) {
if isConfigured {
Button("Чат с \(task.name)") {
switchChatProvider(provider)
}
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.85))
.buttonStyle(.plain)
}
} else if task.id == "integration_music" {
#if !APPSTORE
Button("Открыть Музыку") { MusicController.shared.openMusic() }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.85))
.buttonStyle(.plain)
if appState.musicAutomationDenied {
Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() }
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.buttonStyle(.plain)
}
#endif
} else if n8nHasActivity {
// Clickable pill — tap to open execution detail
let success = task.state == .finished
let accent = success ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
Button(action: {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true }
}) {
HStack(spacing: 5) {
Circle().fill(accent).frame(width: 5, height: 5)
Text(task.steps.first ?? "Сценарий")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail)
Image(systemName: "ellipsis")
.font(.system(size: 8, weight: .medium))
.foregroundColor(Color(hex: "#6B7079"))
}
.padding(.horizontal, 8).padding(.vertical, 3)
.background(accent.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(accent.opacity(0.22), lineWidth: 1))
}
.buttonStyle(.plain)
} else if let url = openURL {
Button("Открыть \(task.name)") { NSWorkspace.shared.open(url) }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: task.color).opacity(0.85))
.buttonStyle(.plain)
}
if task.id == "integration_stripe" && isConfigured {
Button("Обновить") { Task { @MainActor in StripePoller.shared.pollNow() } }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#0570DE").opacity(0.85))
.buttonStyle(.plain)
}
if task.id == "integration_calcom" && isConfigured {
Button("Обновить") { Task { @MainActor in CalcomPoller.shared.pollNow() } }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#C9956A").opacity(0.85))
.buttonStyle(.plain)
}
// Settings button: shown when not configured, except cursor/codex and music
if !isConfigured
&& task.id != "agent_cursor"
&& task.id != "agent_codex"
&& task.id != "integration_music" {
Button("Настройки…") {
let section: String
switch PillCatalog.definition(for: task.id)?.category {
case .workspace, .agent: section = "agents"
case .ai: section = "chat"
default: section = "integrations"
}
NotificationCenter.default.post(name: .openFullSettings, object: section)
}
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.buttonStyle(.plain)
}
}
.padding(.leading, 108)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
.transition(.opacity)
}
}
private func openVSCode() {
let ids = ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders", "com.vscodium.codium"]
let appURL = ids.compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }.first
// If we have a project folder, open it directly in VS Code
if let cwd = task.sessionCwd, !cwd.isEmpty, let appURL = appURL {
NSWorkspace.shared.open(
[URL(fileURLWithPath: cwd)],
withApplicationAt: appURL,
configuration: .init(),
completionHandler: nil
)
return
}
// No cwd: activate running instance or launch fresh
if let running = ids.compactMap({ id in
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
}).first {
running.activate(options: .activateIgnoringOtherApps)
return
}
if let appURL = appURL {
NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
}
}
}
// MARK: - Vercel Deployment List View
struct VercelDeploymentListView: View {
let deployments: [VercelDeployment]
let onOpenDetail: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#7C5CFF"))
.frame(width: 7, height: 7)
Text("Vercel")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Text("Деплои")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
// Deployment rows
VStack(alignment: .leading, spacing: 3) {
// First deployment — highlighted, with detail button
if let first = deployments.first {
let accent = Color(hex: first.isSuccess ? "#22C55E" : "#F4505E")
HStack(spacing: 5) {
Circle().fill(accent).frame(width: 5, height: 5)
Text(first.projectName)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail)
.layoutPriority(1)
Text(first.timeAgo)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
Button(action: onOpenDetail) {
Image(systemName: "ellipsis")
.font(.system(size: 8, weight: .medium))
.foregroundColor(Color(hex: "#6B7079"))
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 5))
}
// 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"))
}
.padding(.horizontal, 8).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.top, 5)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
}
// 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 == "только что" ? deployment.timeAgo : deployment.timeAgo + " назад")
.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 {
@State private var on = false
var body: some View {
Circle()
.fill(Color(hex: "#22C55E"))
.frame(width: 4, height: 4)
.opacity(on ? 1 : 0.2)
.onAppear {
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { on = true }
}
}
}
struct ResendCardView: View {
let emails: [ResendEmail]
let total: Int?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#22C55E"))
.frame(width: 7, height: 7)
Text("Resend")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Text("Письма")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
if let total {
ResendPulseDot()
Text("\(total)")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#C5C8CD"))
.monospacedDigit()
}
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
// Email rows — first is highlighted, rest plain (same structure as Vercel list)
VStack(alignment: .leading, spacing: 3) {
if let first = emails.first {
let accent = Color(hex: first.isDelivered ? "#22C55E" : "#F4505E")
HStack(spacing: 5) {
Circle().fill(accent).frame(width: 5, height: 5)
Text(first.recipientShort)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail)
.layoutPriority(1)
Text(first.timeAgo)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
if !first.subject.isEmpty {
Text(first.subject)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#4D5159"))
.lineLimit(1).truncationMode(.tail)
}
}
.padding(.horizontal, 8).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 5))
}
ForEach(Array(emails.dropFirst().prefix(2))) { email in
let accent = Color(hex: email.isDelivered ? "#22C55E" : "#F4505E")
HStack(spacing: 5) {
Circle().fill(accent).frame(width: 5, height: 5)
Text(email.recipientShort)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#9398A1"))
.lineLimit(1).truncationMode(.tail)
.layoutPriority(1)
Text(email.timeAgo)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
}
.padding(.horizontal, 8).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.top, 5)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
}
// MARK: - GitHub Pulse Card View
private func ciWorstState(_ prs: [GitHubPR]) -> CIState {
if prs.contains(where: { $0.ci == .failure }) { return .failure }
if prs.contains(where: { $0.ci == .pending }) { return .pending }
if prs.contains(where: { $0.ci == .success }) { return .success }
return .unknown
}
private func ciColor(_ state: CIState) -> String {
switch state {
case .failure: return "#F4505E"
case .pending: return "#F5A524"
case .success: return "#22C55E"
case .unknown: return "#6B7079"
}
}
private func mainCIWorst(_ repos: [GitHubRepoCI]) -> CIState {
if repos.contains(where: { $0.ci == .failure }) { return .failure }
if repos.contains(where: { $0.ci == .pending }) { return .pending }
if repos.contains(where: { $0.ci == .success }) { return .success }
return .unknown
}
struct GitHubPulseCardView: View {
let pulse: GitHubPulse
let stats: GitHubStats?
let activity: GitHubActivity?
let onTapSection: (GitHubDetailSection) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#F4505E"))
.frame(width: 7, height: 7)
Text("GitHub")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
if let s = stats {
// Stars + 7-day mini-row → opens Activity detail
Button(action: { onTapSection(.activity) }) {
HStack(spacing: 4) {
Text("★ \(formatCount(s.totalStars))")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
if let act = activity {
HStack(spacing: 2) {
ForEach(act.lastDays(7), id: \.date) { day in
RoundedRectangle(cornerRadius: 1.5)
.fill(contributionColor(day.level))
.frame(width: 7, height: 7)
}
}
}
}
}
.buttonStyle(.plain)
} else if let act = activity {
// No stats yet but activity loaded — show mini-row only
Button(action: { onTapSection(.activity) }) {
HStack(spacing: 2) {
ForEach(act.lastDays(7), id: \.date) { day in
RoundedRectangle(cornerRadius: 1.5)
.fill(contributionColor(day.level))
.frame(width: 7, height: 7)
}
}
}
.buttonStyle(.plain)
} else {
Text("Обзор")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
// Stat rows
VStack(alignment: .leading, spacing: 4) {
// My PRs
let prWorst = ciWorstState(pulse.myPRs)
let prValue: String = {
let n = pulse.myPRs.count
if n == 0 { return "0" }
let failing = pulse.myPRs.filter { $0.ci == .failure }.count
let pending = pulse.myPRs.filter { $0.ci == .pending }.count
if failing > 0 { return "\(n) · \(failing) с ошибкой" }
if pending > 0 { return "\(n) · идёт" }
return "\(n)"
}()
GitHubStatRow(
icon: "arrow.triangle.pull", iconColor: ciColor(prWorst),
label: "Мои PR", value: prValue
) { onTapSection(.myPRs) }
// To review
let reviewCount = pulse.toReview.count
GitHubStatRow(
icon: "eye",
iconColor: reviewCount > 0 ? "#8AB4F8" : "#6B7079",
label: "На ревью",
value: "\(reviewCount)"
) { onTapSection(.toReview) }
// Default branch CI
let mainWorst = mainCIWorst(pulse.mainCI)
let (ciIcon, ciIconColor, ciValue): (String, String, String) = {
switch mainWorst {
case .failure:
let n = pulse.mainCI.filter { $0.ci == .failure }.count
return ("xmark.octagon.fill", "#F4505E", "\(n) с ошибкой")
case .pending:
return ("checkmark.seal.fill", "#F5A524", "идёт")
case .success:
return ("checkmark.seal.fill", "#22C55E", "всё зелёное")
case .unknown:
return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "нет репо" : "неизвестно")
}
}()
GitHubStatRow(
icon: ciIcon, iconColor: ciIconColor,
label: "CI основной ветки", value: ciValue
) { onTapSection(.mainCI) }
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
.clipped()
}
private func formatCount(_ n: Int) -> String {
if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) }
return "\(n)"
}
}
private struct GitHubStatRow: View {
let icon: String
let iconColor: String
let label: String
let value: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 7) {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(Color(hex: iconColor))
.frame(width: 14)
Text(label)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
Spacer()
Text(value)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#C5C8CD"))
.monospacedDigit()
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
}
// MARK: - GitHub Detail View
struct GitHubDetailView: View {
let section: GitHubDetailSection
let pulse: GitHubPulse
let activity: GitHubActivity?
let stats: GitHubStats?
let onBack: () -> Void
@ObservedObject private var appState = AppState.shared
private var title: String {
switch section {
case .myPRs: return "Мои PR"
case .toReview: return "На ревью"
case .mainCI: return "CI основной ветки"
case .activity: return "Активность"
}
}
private var items: [GitHubPR] {
switch section {
case .myPRs: return pulse.myPRs
case .toReview: return pulse.toReview
case .mainCI, .activity: return []
}
}
private var repoItems: [GitHubRepoCI] {
section == .mainCI ? pulse.mainCI : []
}
private var totalItems: Int { items.count + repoItems.count }
var body: some View {
if section == .activity {
GitHubActivityDetailContent(
activity: activity,
stats: stats,
login: pulse.login,
onBack: onBack
)
} else {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Button(action: onBack) {
HStack(spacing: 3) {
Image(systemName: "chevron.left")
.font(.system(size: 8, weight: .medium))
Text(title)
.font(.system(size: 12, weight: .semibold))
}
.foregroundColor(Color(hex: "#F5F6F8"))
}
.buttonStyle(.plain)
Spacer(minLength: 2)
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 12)
// List
if items.isEmpty && repoItems.isEmpty {
Text("Пусто")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#6B7079"))
.padding(.top, 8)
.padding(.leading, 108)
} else {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, pr in
GitHubPRRowView(pr: pr, showCI: section == .myPRs,
selected: appState.cardSelection == idx)
}
ForEach(Array(repoItems.enumerated()), id: \.element.repo) { idx, repo in
GitHubRepoCIRowView(repo: repo,
selected: appState.cardSelection == items.count + idx)
}
}
}
.frame(maxHeight: 60) // 3 rows × 20 pt; rest scrolls
.mask(
Group {
if totalItems > 3 {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.8),
.init(color: .clear, location: 1.0)
],
startPoint: .top, endPoint: .bottom
)
} else {
Color.black
}
}
)
.padding(.top, 4)
.padding(.leading, 108)
.padding(.trailing, 8)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
.clipped()
.onAppear {
GithubPoller.shared.refreshIfStale()
appState.cardItemCount = totalItems
}
.onDisappear { appState.cardItemCount = 0 }
.onReceive(NotificationCenter.default.publisher(for: .islandActivateCardSelection)) { _ in
guard let sel = appState.cardSelection else { return }
if sel < items.count {
let pr = items[sel]
if let url = safeWebURL(pr.url), url.host == "github.com" {
NSWorkspace.shared.open(url)
}
} else {
let repoIdx = sel - items.count
guard repoIdx < repoItems.count else { return }
let repo = repoItems[repoIdx]
let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions"
if let url = safeWebURL(actionsURL), url.host == "github.com" {
NSWorkspace.shared.open(url)
}
}
}
.onExitCommand { onBack() }
}
}
}
// MARK: - GitHub Activity Detail
private struct GitHubActivityDetailContent: View {
let activity: GitHubActivity?
let stats: GitHubStats?
let login: String
let onBack: () -> Void
@State private var hoveredDay: ContributionDay? = nil
// Dynamic grid: s=7pt, spacing=1.5pt; numWeeks = floor((202 + 1.5) / (7 + 1.5)) = 23
private let squareSize: CGFloat = 7
private let spacing: CGFloat = 1.5
private var numWeeks: Int { Int((202 + spacing) / (squareSize + spacing)) }
private var headerRight: String {
if let day = hoveredDay {
let label: String
switch day.count {
case 0: label = "Нет вкладов"
default: label = "\(day.count) \(ruPlural(day.count, "вклад", "вклада", "вкладов"))"
}
return "\(activityDateLabel(day.date)) · \(label)"
}
guard let act = activity else { return "" }
let total = activityTotalLabel(act.total)
if let s = stats { return "\(total) за год · \(s.totalRepos) репо" }
return "\(total) за год"
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Button(action: onBack) {
HStack(spacing: 3) {
Image(systemName: "chevron.left")
.font(.system(size: 8, weight: .medium))
Text("Активность")
.font(.system(size: 12, weight: .semibold))
}
.foregroundColor(Color(hex: "#F5F6F8"))
}
.buttonStyle(.plain)
Spacer(minLength: 2)
if activity != nil {
Button(action: {
let urlStr = "https://github.com/\(login)"
if let url = safeWebURL(urlStr), url.host == "github.com" {
NSWorkspace.shared.open(url)
}
}) {
Text(headerRight)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
}
.buttonStyle(.plain)
}
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 12)
// Grid
if let act = activity {
let weeks = act.lastWeeks(numWeeks)
HStack(alignment: .top, spacing: spacing) {
ForEach(weeks.indices, id: \.self) { wi in
VStack(spacing: spacing) {
ForEach(0..<7, id: \.self) { dow in
if let day = weeks[wi].first(where: { $0.weekday == dow }) {
RoundedRectangle(cornerRadius: 1.5)
.fill(contributionColor(day.level))
.frame(width: squareSize, height: squareSize)
.onHover { hovering in hoveredDay = hovering ? day : nil }
.onTapGesture {
hoveredDay = (hoveredDay?.date == day.date) ? nil : day
}
} else {
Color.clear.frame(width: squareSize, height: squareSize)
}
}
}
}
}
.padding(.top, 5)
.padding(.leading, 108)
.padding(.trailing, 12)
} else {
Text("Загрузка…")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#6B7079"))
.padding(.top, 8)
.padding(.leading, 108)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
.clipped()
.onAppear { GithubPoller.shared.refreshActivityIfStale() }
.onExitCommand { onBack() }
}
private func activityDateLabel(_ dateStr: String) -> String {
let parts = dateStr.split(separator: "-")
guard parts.count == 3,
let month = Int(parts[1]), month >= 1 && month <= 12,
let day = Int(parts[2]) else { return dateStr }
let months = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"]
return "\(day) \(months[month - 1])"
}
private func activityTotalLabel(_ n: Int) -> String {
let nf = NumberFormatter()
nf.numberStyle = .decimal
nf.locale = Locale(identifier: "ru_RU")
return nf.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
private func contributionColor(_ level: Int) -> Color {
switch level {
case 1: return Color(hex: "#0E4429")
case 2: return Color(hex: "#006D32")
case 3: return Color(hex: "#26A641")
case 4: return Color(hex: "#39D353")
default: return Color.white.opacity(0.06)
}
}
private func ghCIDot(_ ci: CIState) -> Color {
switch ci {
case .failure: return Color(hex: "#F4505E")
case .pending: return Color(hex: "#F5A524")
case .success: return Color(hex: "#22C55E")
case .unknown: return Color.clear
}
}
private struct GitHubPRRowView: View {
let pr: GitHubPR
let showCI: Bool
var selected: Bool = false
var body: some View {
Button(action: {
if let url = safeWebURL(pr.url), url.host == "github.com" {
NSWorkspace.shared.open(url)
}
}) {
HStack(spacing: 5) {
if showCI {
Circle()
.fill(ghCIDot(pr.ci))
.frame(width: 5, height: 5)
.opacity(pr.ci == .unknown ? 0 : 1)
} else {
Spacer().frame(width: 5)
}
Text("\(pr.repo.components(separatedBy: "/").last ?? pr.repo)#\(pr.number)")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#9398A1"))
.lineLimit(1)
Text(pr.title)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1)
.truncationMode(.tail)
if pr.isDraft {
Text("Черновик")
.font(.system(size: 9.5))
.foregroundColor(Color(hex: "#6B7079"))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 20)
.overlay(
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.12))
.opacity(selected ? 1 : 0)
)
}
.buttonStyle(.plain)
}
}
private struct GitHubRepoCIRowView: View {
let repo: GitHubRepoCI
var selected: Bool = false
private var ciStateWord: String? {
switch repo.ci {
case .failure: return "failing"
case .pending: return "running"
case .success: return "passing"
case .unknown: return nil
}
}
var body: some View {
Button(action: {
let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions"
if let url = safeWebURL(actionsURL), url.host == "github.com" {
NSWorkspace.shared.open(url)
}
}) {
HStack(spacing: 5) {
Circle()
.fill(ghCIDot(repo.ci))
.frame(width: 5, height: 5)
.opacity(repo.ci == .unknown ? 0 : 1)
Text(repo.repo.components(separatedBy: "/").last ?? repo.repo)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#9398A1"))
.lineLimit(1)
Text(repo.branch)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 4)
if let word = ciStateWord {
Text(word)
.font(.system(size: 10))
.foregroundColor(ghCIDot(repo.ci))
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 20)
.overlay(
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.12))
.opacity(selected ? 1 : 0)
)
}
.buttonStyle(.plain)
}
}
// MARK: - GitHub Stats Card View
struct GitHubStatsCardView: View {
let stats: GitHubStats
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#F4505E"))
.frame(width: 7, height: 7)
Text("GitHub")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Text("Обзор")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
// Stats rows
VStack(alignment: .leading, spacing: 5) {
StatRow(icon: "star.fill", color: "#F5A524",
label: "Всего звёзд", value: formatCount(stats.totalStars))
StatRow(icon: "square.stack.fill", color: "#6B7079",
label: "Репозитории", value: "\(stats.totalRepos)")
}
.padding(.top, 8)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
private func formatCount(_ n: Int) -> String {
if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) }
return "\(n)"
}
}
private struct StatRow: View {
let icon: String
let color: String
let label: String
let value: String
var body: some View {
HStack(spacing: 7) {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(Color(hex: color))
.frame(width: 14)
Text(label)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
Spacer()
Text(value)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#C5C8CD"))
.monospacedDigit()
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Stripe Card View
struct StripeCardView: View {
@ObservedObject private var appState = AppState.shared
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#0570DE"))
.frame(width: 7, height: 7)
Text("Stripe")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Text("Платежи")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
// Balance
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text(balanceFormatted)
.font(.system(size: 20, weight: .bold, design: .monospaced))
.foregroundColor(Color(hex: "#F5F6F8"))
.contentTransition(.numericText(countsDown: false))
.animation(.easeOut(duration: 1.2), value: appState.stripeDisplayBalance)
Text(appState.stripeCurrency.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079"))
.padding(.bottom, 1)
}
.padding(.leading, 108)
.padding(.top, 4)
// Payment rows (animated list)
VStack(alignment: .leading, spacing: 2) {
ForEach(appState.stripePayments) { payment in
StripePaymentRow(payment: payment)
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
))
}
}
.animation(.spring(response: 0.38, dampingFraction: 0.82),
value: appState.stripePayments.map(\.id))
.padding(.leading, 108)
.padding(.trailing, 12)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
private var balanceFormatted: String {
String(format: "%.2f", Double(appState.stripeDisplayBalance) / 100.0)
}
}
private struct StripePaymentRow: View {
let payment: StripePayment
var body: some View {
let accent = payment.isSuccess ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
HStack(spacing: 5) {
Circle().fill(accent).frame(width: 5, height: 5)
Text(payment.description ?? "Платёж")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail)
.layoutPriority(1)
Spacer(minLength: 4)
Text("+\(payment.amountFormatted)")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#22C55E"))
.fixedSize()
Text(payment.timeAgo)
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
.fixedSize()
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Cal.com Card View
struct CalcomCardView: View {
@ObservedObject private var appState = AppState.shared
@State private var selectedDate: Date? = nil
@State private var selectedBooking: CalcomBooking? = nil
@State private var displayMonth: Date = Date()
@State private var displayHalf: Int = 1 // 1 = first half, 2 = second half
var body: some View {
Group {
if let booking = selectedBooking {
CalcomBookingDetailView(booking: booking) {
withAnimation(.easeOut(duration: 0.2)) { selectedBooking = nil }
}
} else if let date = selectedDate {
CalcomDayView(
date: date,
bookings: bookingsFor(date),
onSelect: { b in withAnimation(.easeOut(duration: 0.2)) { selectedBooking = b } },
onBack: { withAnimation(.easeOut(duration: 0.2)) { selectedDate = nil } }
)
} else {
CalcomCalendarView(
displayMonth: $displayMonth,
displayHalf: $displayHalf,
bookings: appState.calcomBookings,
onSelect: { d in withAnimation(.easeOut(duration: 0.2)) { selectedDate = d } }
)
}
}
.onChange(of: appState.focusId) { _, _ in
selectedDate = nil; selectedBooking = nil; displayHalf = 1
}
}
private func bookingsFor(_ date: Date) -> [CalcomBooking] {
let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))"
return appState.calcomBookings.filter { $0.dayKey == key }
.sorted { $0.startTime < $1.startTime }
}
}
struct CalcomCalendarView: View {
@Binding var displayMonth: Date
@Binding var displayHalf: Int
let bookings: [CalcomBooking]
let onSelect: (Date) -> Void
private let cal = Calendar.current
private var navLabel: String {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "LLLL yyyy"
return "\(f.string(from: displayMonth)) Q\(displayHalf)"
}
// 7 consecutive days per row, day 1 always at far left — no weekday alignment
private var allWeeks: [[Date?]] {
let comps = cal.dateComponents([.year, .month], from: displayMonth)
let monthStart = cal.date(from: comps)!
let daysInMonth = cal.range(of: .day, in: .month, for: displayMonth)!.count
var result: [[Date?]] = []
var chunk: [Date?] = []
for i in 0..<daysInMonth {
chunk.append(cal.date(byAdding: .day, value: i, to: monthStart)!)
if chunk.count == 7 { result.append(chunk); chunk = [] }
}
if !chunk.isEmpty {
while chunk.count < 7 { chunk.append(nil) }
result.append(chunk)
}
return result
}
// Visible weeks for current half
private var visibleWeeks: [[Date?]] {
let all = allWeeks
let splitAt = 2 // always 2 weeks per Q
return displayHalf == 1 ? Array(all[0..<splitAt]) : Array(all[splitAt...])
}
private func hasBookings(_ d: Date) -> Bool {
let c = cal.dateComponents([.year, .month, .day], from: d)
let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))"
return bookings.contains { $0.dayKey == key }
}
private func goBack() {
if displayHalf == 1 {
displayMonth = cal.date(byAdding: .month, value: -1, to: displayMonth) ?? displayMonth
displayHalf = 2
} else {
displayHalf = 1
}
}
private func goForward() {
if displayHalf == 1 {
displayHalf = 2
} else {
displayMonth = cal.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
displayHalf = 1
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#C9956A")).frame(width: 7, height: 7)
Text("Cal.com").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
Text("Расписание").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
}
.padding(.top, 6).padding(.leading, 108).padding(.trailing, 36)
HStack(spacing: 0) {
Button { goBack() } label: {
Image(systemName: "chevron.left").font(.system(size: 8, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16)
}.buttonStyle(.plain)
Text(navLabel).font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: "#C5C8CD")).frame(maxWidth: .infinity)
Button { goForward() } label: {
Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16)
}.buttonStyle(.plain)
}
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 2)
VStack(spacing: 1) {
ForEach(visibleWeeks.indices, id: \.self) { i in
CalcomWeekRow(week: visibleWeeks[i], hasBookings: hasBookings,
isToday: cal.isDateInToday, onSelect: onSelect)
}
}
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
.transition(.opacity)
}
}
private struct CalcomWeekRow: View {
let week: [Date?]
let hasBookings: (Date) -> Bool
let isToday: (Date) -> Bool
let onSelect: (Date) -> Void
private var weekLabel: String {
guard let first = week.compactMap({ $0 }).first else { return "" }
let f = DateFormatter(); f.dateFormat = "dd.MM"
return f.string(from: first)
}
var body: some View {
HStack(spacing: 0) {
Text(weekLabel).font(.system(size: 7)).foregroundColor(Color(hex: "#4B5563"))
.frame(width: 26, alignment: .leading)
ForEach(0..<7, id: \.self) { i in
if let day = week[i] {
CalcomDayCell(day: day, hasEvents: hasBookings(day), isToday: isToday(day))
.contentShape(Rectangle()).onTapGesture { onSelect(day) }.frame(maxWidth: .infinity)
} else {
Color.clear.frame(maxWidth: .infinity).frame(height: 18)
}
}
}
}
}
private struct CalcomDayCell: View {
let day: Date
let hasEvents: Bool
let isToday: Bool
var body: some View {
VStack(spacing: 1) {
Text("\(Calendar.current.component(.day, from: day))")
.font(.system(size: 9, weight: isToday ? .bold : .regular))
.foregroundColor(isToday ? .white : Color(hex: "#9398A1"))
.frame(width: 13, height: 13)
.background(isToday ? Color(hex: "#C9956A").opacity(0.55) : Color.clear)
.clipShape(Circle())
Circle().fill(hasEvents ? Color(hex: "#C9956A") : Color.clear).frame(width: 3, height: 3)
}
.frame(height: 18)
}
}
struct CalcomDayView: View {
let date: Date
let bookings: [CalcomBooking]
let onSelect: (CalcomBooking) -> Void
let onBack: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 4) {
Button(action: onBack) {
Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle())
}.buttonStyle(.plain).padding(.leading, 108)
Text(dayLabel).font(.system(size: 11, weight: .semibold)).foregroundColor(Color(hex: "#C5C8CD"))
Spacer()
}
.padding(.top, 6).padding(.trailing, 12)
if bookings.isEmpty {
Text("Звонков нет").font(.system(size: 11)).foregroundColor(Color(hex: "#6B7079"))
.padding(.leading, 116).padding(.top, 8)
} else {
VStack(alignment: .leading, spacing: 3) {
ForEach(bookings) { b in
Button { onSelect(b) } label: {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#C9956A")).frame(width: 4, height: 4)
Text(b.timeLabel)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#C9956A")).fixedSize()
Text(b.title).font(.system(size: 11)).foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail).layoutPriority(1)
Spacer(minLength: 2)
Image(systemName: "chevron.right").font(.system(size: 8))
.foregroundColor(Color(hex: "#4B5563"))
}
.padding(.horizontal, 6).padding(.vertical, 3)
.background(Color(hex: "#C9956A").opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 4))
}.buttonStyle(.plain)
}
}
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 5)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
.transition(.opacity)
}
private var dayLabel: String {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "EEEE, d MMMM"; return f.string(from: date)
}
}
struct CalcomBookingDetailView: View {
let booking: CalcomBooking
let onBack: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 4) {
Button(action: onBack) {
Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle())
}.buttonStyle(.plain).padding(.leading, 108)
Text(booking.timeLabel)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#C9956A"))
Spacer()
}
.padding(.top, 6).padding(.trailing, 12)
VStack(alignment: .leading, spacing: 4) {
Text(booking.title).font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8")).lineLimit(1)
if let name = booking.attendeeName, !name.isEmpty {
CalcomDetailRow(icon: "person.fill", text: name, size: 11)
}
if let email = booking.attendeeEmail, !email.isEmpty {
CalcomDetailRow(icon: "envelope.fill", text: email, size: 10, truncate: true)
}
if let notes = booking.attendeeNotes, !notes.isEmpty {
CalcomDetailRow(icon: "note.text", text: notes, size: 10, lines: 2)
}
}
.padding(.leading, 114).padding(.trailing, 12).padding(.top, 5)
}
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
.transition(.opacity)
}
}
private struct CalcomDetailRow: View {
let icon: String
let text: String
var size: CGFloat = 11
var truncate: Bool = false
var lines: Int = 1
var body: some View {
HStack(alignment: .top, spacing: 4) {
Image(systemName: icon).font(.system(size: 9)).foregroundColor(Color(hex: "#6B7079")).frame(width: 10)
Text(text).font(.system(size: size)).foregroundColor(Color(hex: "#9398A1"))
.lineLimit(lines).truncationMode(truncate ? .middle : .tail)
}
}
}
// MARK: - Notion Card View
struct NotionCardView: View {
@ObservedObject private var appState = AppState.shared
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#E8E8E8")).frame(width: 7, height: 7)
Text("Notion").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
Text("Недавние").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
}
.padding(.top, 6).padding(.leading, 108).padding(.trailing, 36)
VStack(alignment: .leading, spacing: 2) {
ForEach(appState.notionPages.prefix(3)) { page in
Button {
if let url = safeWebURL(page.url) { NSWorkspace.shared.open(url) }
} label: {
HStack(spacing: 6) {
if let emoji = page.emoji {
Text(emoji).font(.system(size: 10)).frame(width: 14)
} else {
Image(systemName: "doc.text").font(.system(size: 9))
.foregroundColor(Color(hex: "#6B7079")).frame(width: 14)
}
Text(page.title).font(.system(size: 11))
.foregroundColor(Color(hex: "#C5C8CD"))
.lineLimit(1).truncationMode(.tail).layoutPriority(1)
Spacer(minLength: 4)
Text(page.timeAgo).font(.system(size: 9))
.foregroundColor(Color(hex: "#4B5563"))
}
.padding(.horizontal, 6).padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.leading, 102).padding(.trailing, 12).padding(.top, 5)
}
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
.transition(.opacity)
}
}
// MARK: - n8n Execution Detail View
struct N8nDetailView: View {
let task: AgentTask
let onClose: () -> Void
private var success: Bool { task.state == .finished }
private var accent: Color { success ? Color(hex: "#22C55E") : Color(hex: "#F4505E") }
private var statusLabel: String { success ? "Успех" : "Ошибка" }
private var detail: String? { task.steps.dropFirst().first }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header: back button + workflow name + status badge
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) // large hit area
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Circle().fill(accent).frame(width: 6, height: 6)
Text(task.steps.first ?? "Сценарий")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(1).truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 2)
Text(statusLabel)
.font(.system(size: 10, weight: .medium))
.foregroundColor(accent)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(accent.opacity(0.14))
.clipShape(Capsule())
}
// Detail body — monospaced, selectable
if let detail {
ScrollView(.vertical, showsIndicators: false) {
Text(detail)
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(Color(hex: "#9398A1"))
.frame(maxWidth: .infinity, alignment: .leading)
.lineSpacing(2)
.textSelection(.enabled)
}
.frame(maxHeight: 88)
} else {
Text(success ? "Успешно завершено." : "Нет подробностей об ошибке.")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
}
}
.padding(.top, 8)
.padding(.leading, 108)
.padding(.trailing, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.contentShape(Rectangle()) // prevent taps falling through transparent areas
}
}
// MARK: - Ticker (overview scrolling task steps) V2
struct TickerView: View {
let task: AgentTask?
var onDiffTap: ((Int) -> Void)? = nil
@State private var rowA: String = "…" // completed (above, left-shifted)
@State private var rowB: String = "…" // current (below) → animates diagonally up-left
@State private var rowC: String = "" // incoming current — slides in from below
@State private var rowAOffset: CGFloat = 0
@State private var rowAOpacity: Double = 1
@State private var rowBOffset: CGFloat = 22
@State private var rowBPhase: Double = 0 // 0=current, 1=completed (drives X+scale)
@State private var rowCOffset: CGFloat = 44
@State private var rowCOpacity: Double = 0
@State private var displayIndex: Int = -1
@State private var isTransitioning = false
private let completedScale: CGFloat = 11.5 / 13 // 0.885 — matches completed font size
var steps: [String] {
let raw = task?.steps ?? []
return raw.isEmpty ? ["…"] : raw
}
var body: some View {
let isActive = task?.state == .thinking || task?.state == .working
let rowADiffTap: (() -> Void)? = rowA.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } }
let rowBDiffTap: (() -> Void)? = rowB.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } }
ZStack(alignment: .topLeading) {
Color.clear
// Row A: completed row — always rendered at phase=1 + completedScale
TickerRowView(text: rowA, phase: 1.0, isActive: isActive, onDiffTap: rowADiffTap)
.scaleEffect(completedScale, anchor: .leading)
.offset(x: -10, y: rowAOffset)
.opacity(rowAOpacity)
// Row B: current step → animates diagonally up-left, phase 0→1, scale 1→completedScale
TickerRowView(text: rowB, phase: rowBPhase, isActive: isActive, onDiffTap: rowBDiffTap)
.scaleEffect(1 - rowBPhase * (1 - completedScale), anchor: .leading)
.offset(x: -rowBPhase * 10, y: rowBOffset)
// Row C: incoming new step — slides in from below at phase=0
TickerRowView(text: rowC, phase: 0.0, isActive: isActive)
.offset(y: rowCOffset)
.opacity(rowCOpacity)
}
.frame(height: 44)
.clipped()
.mask(LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.12),
.init(color: .black, location: 0.85),
.init(color: .clear, location: 1)
],
startPoint: .top, endPoint: .bottom
))
.onAppear {
let idx = task?.stepIndex ?? -1
displayIndex = idx
if idx >= 0, !steps.isEmpty {
rowA = idx > 0 ? steps[max(0, idx - 1)] : "…"
rowB = steps[min(idx, steps.count - 1)]
}
}
.onChange(of: task?.steps.count) { _, _ in
guard let task, !task.steps.isEmpty, !isTransitioning else { return }
let newIdx = task.stepIndex
if displayIndex < 0 {
displayIndex = newIdx
rowA = newIdx > 0 ? steps[max(0, newIdx - 1)] : "…"
rowB = steps[min(newIdx, steps.count - 1)]
return
}
guard newIdx != displayIndex else { return }
tickerAnimate(to: newIdx)
}
}
private func tickerAnimate(to newIdx: Int) {
isTransitioning = true
rowC = steps[min(newIdx, steps.count - 1)]
rowCOffset = 44
rowCOpacity = 0
// Old completed (rowA): fades + slides further up
withAnimation(.easeOut(duration: 0.28)) {
rowAOffset = -22
rowAOpacity = 0
}
// Current (rowB): moves diagonally up-left + shrinks to completed size
withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) {
rowBOffset = 0
rowBPhase = 1
}
// New current (rowC): slides in from below
withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) {
rowCOffset = 22
rowCOpacity = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) {
self.displayIndex = newIdx
self.rowA = self.rowB
self.rowAOffset = 0
self.rowAOpacity = 1
self.rowB = self.rowC
self.rowBOffset = 22
self.rowBPhase = 0
self.rowCOffset = 44
self.rowCOpacity = 0
self.isTransitioning = false
}
}
}
struct TickerRowView: View {
let text: String
let phase: Double // 0 = current (shimmer, large), 1 = completed (dim, scaled down by caller)
var isActive: Bool = true
var onDiffTap: (() -> Void)? = nil
var body: some View {
let chevronOpacity: Double = isActive ? max(0, 1 - phase * 2) : 0
let checkmarkOpacity: Double = isActive ? max(0, phase * 2 - 1) : 1
let shimmerOpacity: Double = isActive ? max(0, 1 - phase * 1.6) : 0
let staticOpacity: Double = isActive ? min(1, max(0, phase * 2 - 0.4)) : 1
let staticColor = (!isActive && phase < 0.5) ? Color(hex: "#C9CDD4") : Color(hex: "#6B7079")
if let dp = text.parseDiffStep() {
HStack(spacing: 6) {
ZStack {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .medium))
.foregroundColor(Color(hex: "#8E939C"))
.opacity(chevronOpacity)
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .regular))
.foregroundColor(Color(hex: "#454850"))
.opacity(checkmarkOpacity)
}
.frame(width: 12, alignment: .center)
// Filename + counts
HStack(spacing: 0) {
ZStack(alignment: .leading) {
TickerShimmerText(text: dp.filename)
.opacity(shimmerOpacity)
Text(dp.filename)
.font(.system(size: 13, weight: .medium))
.foregroundColor(staticColor)
.lineLimit(1).truncationMode(.tail)
.opacity(staticOpacity)
}
if dp.added > 0 {
Text(" +\(dp.added)")
.font(.system(size: 10, weight: .medium).monospaced())
.foregroundColor(Color(hex: "#22C55E"))
.fixedSize()
}
if dp.removed > 0 {
Text(" −\(dp.removed)")
.font(.system(size: 10, weight: .medium).monospaced())
.foregroundColor(Color(hex: "#F4505E"))
.fixedSize()
}
}
}
.frame(height: 22, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onDiffTap?() }
} else {
HStack(spacing: 6) {
// Icon: chevron fades out first half, checkmark fades in second half
ZStack {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .medium))
.foregroundColor(Color(hex: "#8E939C"))
.opacity(chevronOpacity)
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .regular))
.foregroundColor(Color(hex: "#454850"))
.opacity(checkmarkOpacity)
}
.frame(width: 12, alignment: .center)
// Text: shimmer fades out, dim completed text fades in (overlapping cross-fade)
ZStack(alignment: .leading) {
TickerShimmerText(text: text)
.opacity(shimmerOpacity)
Text(text)
.font(.system(size: 13, weight: .medium))
.foregroundColor(staticColor)
.lineLimit(1).truncationMode(.tail)
.opacity(staticOpacity)
}
}
.frame(height: 22, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct TickerShimmerText: View {
let text: String
var body: some View {
TimelineView(.animation) { tl in
let t = tl.date.timeIntervalSinceReferenceDate
let p = CGFloat(t.truncatingRemainder(dividingBy: 2.2) / 2.2)
// phase sweeps -0.1 → 1.1 so white peak enters from left and exits right
let phase = p * 1.2 - 0.1
Text(text)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(LinearGradient(stops: [
.init(color: Color(hex: "#7c818a"), location: max(0, phase - 0.3)),
.init(color: Color(hex: "#F2F3F5"), location: max(0, min(1, phase))),
.init(color: Color(hex: "#7c818a"), location: min(1, phase + 0.3)),
], startPoint: .leading, endPoint: .trailing))
}
}
}
// MARK: - Agent pills (overview right card)
struct AgentPillsView: View {
@ObservedObject var state: AppState
@State private var swapping = false
private var others: [AgentTask] {
state.tasks.filter { $0.id != state.focusId }
}
private var displayTasks: [AgentTask] {
Array(others.prefix(4))
}
private let columns = [
GridItem(.flexible(), spacing: 4),
GridItem(.flexible(), spacing: 4)
]
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 0)
LazyVGrid(columns: columns, spacing: 4) {
ForEach(displayTasks) { task in
#if !APPSTORE
if task.id == "integration_music" {
MusicPill(task: task, state: state, swapping: $swapping) {
swapping = true
state.setFocus(task.id)
SoundEngine.shared.play("blip")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
}
} else {
AgentPill(task: task, state: state, swapping: $swapping) {
swapping = true
state.setFocus(task.id)
SoundEngine.shared.play("blip")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
}
}
#else
AgentPill(task: task, state: state, swapping: $swapping) {
swapping = true
state.setFocus(task.id)
SoundEngine.shared.play("blip")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
}
#endif
}
}
.padding(.horizontal, 8)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct AgentPill: View {
let task: AgentTask
@ObservedObject var state: AppState
@Binding var swapping: Bool
let onTap: () -> Void
@State private var isHovered = false
private var effectiveColor: String { task.color }
// VS Code pill always shows "VS Code" label regardless of active project name
private var displayName: String {
task.id == "integration_claude" ? "VS Code" : task.name
}
var body: some View {
Button(action: { onTap() }) {
ZStack(alignment: .topTrailing) {
ZStack {
Capsule()
.fill(isHovered
? Color(hex: effectiveColor).opacity(0.18)
: Color(hex: "#0E0F11"))
Capsule()
.stroke(Color(hex: effectiveColor).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
HStack(spacing: 0) {
MiniBotCanvasView(task: task)
.frame(width: 22 / 0.6, height: 22 / 0.6)
.frame(width: 22, height: 22, alignment: .center)
.padding(.leading, 8)
Spacer()
}
Text(displayName)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isHovered
? Color(hex: effectiveColor).lighter(by: 0.3)
: Color(hex: "#6B7079"))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity)
.frame(height: 28)
.shadow(color: Color(hex: effectiveColor).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2)
// Alert badge (approval / finished / error)
if let badge = task.pillBadge {
PillBadgeView(badge: badge, taskColor: effectiveColor)
.offset(x: 3, y: -3)
}
}
}
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.04 : 1.0)
.brightness(isHovered ? 0.06 : 0)
.onHover { newHover in
guard !swapping else { return }
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
}
}
}
// MARK: - Music Pill (GitHub build only)
#if !APPSTORE
struct MusicPill: View {
let task: AgentTask
@ObservedObject var state: AppState
@Binding var swapping: Bool
let onTap: () -> Void
@State private var isHovered = false
private var isPlaying: Bool { AppState.shared.musicPlaying }
private var showControls: Bool { isHovered && MusicController.shared.trackTitle != nil }
var body: some View {
ZStack {
// Selection target — full pill area, receives taps where controls don't
Capsule()
.fill(Color.clear)
.contentShape(Capsule())
.onTapGesture { onTap() }
// Visual fills
Capsule()
.fill(isHovered ? Color(hex: task.color).opacity(0.18) : Color(hex: "#0E0F11"))
.allowsHitTesting(false)
Capsule()
.stroke(Color(hex: task.color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
.allowsHitTesting(false)
// Mini Mochi at leading edge
HStack(spacing: 0) {
MiniBotCanvasView(task: task, isDancing: isPlaying)
.frame(width: 22 / 0.6, height: 22 / 0.6)
.frame(width: 22, height: 22, alignment: .center)
.padding(.leading, 8)
Spacer()
}
.allowsHitTesting(false)
// Title — trailing padding grows on hover to make room for buttons
Text(task.name)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: task.color).lighter(by: 0.3) : Color(hex: "#6B7079"))
.lineLimit(1)
.truncationMode(.tail)
.padding(.leading, 34)
.padding(.trailing, showControls ? 52 : 10)
.frame(maxWidth: .infinity, alignment: .center)
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: showControls)
.allowsHitTesting(false)
// Playback controls — appear on hover when a track is loaded
if showControls {
HStack(spacing: 0) {
Spacer()
HStack(spacing: 2) {
MusicControlButton(icon: isPlaying ? "pause.fill" : "play.fill", color: task.color) {
MusicController.shared.playPause()
}
MusicControlButton(icon: "forward.fill", color: task.color) {
MusicController.shared.nextTrack()
}
}
.padding(.trailing, 4)
}
.transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .trailing)))
}
}
.frame(maxWidth: .infinity)
.frame(height: 28)
.shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2)
.scaleEffect(isHovered ? 1.04 : 1.0)
.brightness(isHovered ? 0.06 : 0)
.onHover { newHover in
guard !swapping else { return }
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
}
}
}
struct MusicControlButton: View {
let icon: String
let color: String
let action: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: action) {
ZStack {
Circle()
.fill(isHovered ? Color(hex: color).opacity(0.18) : Color(hex: "#0E0F11"))
Circle()
.stroke(Color(hex: color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
Image(systemName: icon)
.font(.system(size: 8, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: color).lighter(by: 0.3) : Color(hex: "#6B7079"))
}
.frame(width: 20, height: 20)
.shadow(color: Color(hex: color).opacity(isHovered ? 0.35 : 0), radius: 6)
}
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.1 : 1.0)
.onHover { newHover in
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
}
}
}
#endif
// MARK: - Music Card View (GitHub build only)
#if !APPSTORE
struct MusicCardView: View {
@ObservedObject private var controller = MusicController.shared
@ObservedObject private var appState = AppState.shared
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if appState.musicAutomationDenied {
// Automation denied — prompt user to fix
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#F4505E"))
.frame(width: 7, height: 7)
Text("Apple Music")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Spacer(minLength: 2)
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)
Text("Разреши BroV управлять Музыкой")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.padding(.leading, 108)
.padding(.trailing, 12)
Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() }
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#FA2D48").opacity(0.85))
.buttonStyle(.plain)
.padding(.leading, 108)
.padding(.top, 2)
} else {
// Line 1: dot + title
HStack(spacing: 6) {
Circle()
.fill(Color(hex: "#FA2D48"))
.frame(width: 7, height: 7)
if let title = controller.trackTitle {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(1).truncationMode(.tail)
.frame(maxWidth: 150, alignment: .leading)
}
}
.padding(.top, 6)
.padding(.leading, 108)
// Line 2: artist
if let artist = controller.artist {
Text(artist)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1).truncationMode(.tail)
.frame(maxWidth: 150, alignment: .leading)
.padding(.leading, 108)
}
// Line 3: controls
HStack(spacing: 8) {
Button(action: { MusicController.shared.previousTrack() }) {
Image(systemName: "backward.fill")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
Button(action: { MusicController.shared.playPause() }) {
Image(systemName: appState.musicPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#FA2D48"))
}
.buttonStyle(.plain)
Button(action: { MusicController.shared.nextTrack() }) {
Image(systemName: "forward.fill")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
}
.padding(.leading, 108)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
}
}
#endif
struct PillBadgeView: View {
let badge: PillBadge
let taskColor: String
private var badgeColor: Color {
switch badge {
case .approval: return Color(hex: "#F5A524")
case .finished: return Color(hex: "#22C55E")
case .error: return Color(hex: "#F4505E")
}
}
private var icon: String {
switch badge {
case .approval: return "exclamationmark"
case .finished: return "checkmark"
case .error: return "xmark"
}
}
var body: some View {
ZStack {
Circle()
.fill(Color(hex: "#0B0C0E"))
.frame(width: 14, height: 14)
Circle()
.fill(badgeColor)
.frame(width: 12, height: 12)
Image(systemName: icon)
.font(.system(size: 6, weight: .bold))
.foregroundColor(.black)
}
.shadow(color: badgeColor.opacity(0.6), radius: 4, x: 0, y: 0)
}
}
// MARK: - Column agents (right side of non-overview views)
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 && $0.id != "integration_claude" }
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(others.prefix(4).enumerated()), id: \.1.id) { idx, task in
MiniBotCanvasView(task: task)
.frame(width: 16 / 0.6, height: 16 / 0.6)
.frame(width: 16, height: 16)
.position(x: 0, y: CGFloat(50 + idx * 24))
.animation(.spring(response: 0.5, dampingFraction: 0.72).delay(Double(idx) * 0.035), value: idx)
}
}
}
}
// MARK: - Wardrobe
struct WardrobeView: View {
@ObservedObject var state: AppState
@State private var hoveredOutfit: Outfit? = nil
private let columns = Array(repeating: GridItem(.fixed(30), spacing: 5), count: 14)
private var headerRight: String {
// Hover takes priority: show hovered outfit name
if let h = hoveredOutfit {
if h == .auto {
let seasonal = Outfit.seasonal(for: Date(), calendar: .current)
let name = seasonal == .none ? "Нет" : seasonal.displayName
return "Авто · по сезону (сейчас: \(name))"
}
return h.displayName
}
// Fall back to current selection
let sel = state.mochiOutfitSelection
if sel == .auto {
let seasonal = Outfit.seasonal(for: Date(), calendar: .current)
let name = seasonal == .none ? "Нет" : seasonal.displayName
return "Авто · \(name)"
}
return sel.displayName
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
Text("Гардероб")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Spacer(minLength: 4)
Text(headerRight)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
}
.padding(.top, 6)
.padding(.horizontal, 10)
// Grid
let allOutfits = Outfit.allCases.filter { $0 != .auto }
let withAuto = [Outfit.auto] + allOutfits
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 5) {
ForEach(withAuto, id: \.rawValue) { outfit in
OutfitPillView(
outfit: outfit,
isSelected: state.mochiOutfitSelection == outfit,
isHovered: hoveredOutfit == outfit,
onHover: { h in
hoveredOutfit = h ? outfit : nil
if h {
// Preview on main Mochi
let preview: Outfit = outfit == .auto
? Outfit.seasonal(for: Date(), calendar: .current)
: outfit
state.wardrobePreviewOutfit = preview
} else if hoveredOutfit == nil {
state.wardrobePreviewOutfit = nil
}
},
onTap: {
guard state.mochiOutfitSelection != outfit else { return }
state.mochiOutfitSelection = outfit
SoundEngine.shared.play("pop")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
},
seasonalOutfit: outfit == .auto ? state.resolvedOutfit : .none
)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
.frame(maxHeight: .infinity)
.mask(
LinearGradient(
stops: [
.init(color: .white, location: 0),
.init(color: .white, location: 0.85),
.init(color: .clear, location: 1)
],
startPoint: .top, endPoint: .bottom
)
)
}
.padding(.leading, 108)
.padding(.trailing, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onDisappear {
state.wardrobePreviewOutfit = nil
hoveredOutfit = nil
}
.onExitCommand {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
state.view = .overview
}
}
}
}
struct OutfitPillView: View {
let outfit: Outfit
let isSelected: Bool
let isHovered: Bool
let onHover: (Bool) -> Void
let onTap: () -> Void
var seasonalOutfit: Outfit = .none
var body: some View {
Canvas { context, size in
drawOutfitIcon(context: context, size: size, outfit: outfit, seasonal: seasonalOutfit)
}
.frame(width: 30, height: 30)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isHovered ? Color.white.opacity(0.10) : Color.white.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected ? Color.white.opacity(0.40) : Color.white.opacity(0.08), lineWidth: 1)
)
.onHover { onHover($0) }
.onTapGesture { onTap() }
}
}
private func drawOutfitIcon(context: GraphicsContext, size: CGSize, outfit: Outfit, seasonal: Outfit = .none) {
let W = size.width, H = size.height
let cx = W / 2, cy = H / 2
let R: CGFloat = 6.5 // small scale for icon
switch outfit {
case .auto:
let iconR: CGFloat = 10.0
let rx = iconR * 1.14, ry = iconR * 0.88
let mH = MochiH(R: iconR, yaw: 0, pitch: 0)
let bodyPath = mochiOutfitPath(rx, ry)
let iconCY = cy + iconR * 0.62
// Draw the seasonal outfit behind body
if seasonal != .none && seasonal != .auto {
drawOutfitBehindStatic(context: context, outfit: seasonal, H: mH,
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
roll: 0, morph: 0, isMini: false)
}
// Body
var ctx = context
ctx.translateBy(x: cx, y: iconCY)
ctx.fill(bodyPath, with: .linearGradient(
Gradient(colors: [Color(red: 0.929, green: 0.929, blue: 0.937),
Color(red: 0.769, green: 0.773, blue: 0.792)]),
startPoint: CGPoint(x: rx*0.7, y: -ry*0.85),
endPoint: CGPoint(x: -rx*0.8, y: ry*0.9)
))
ctx.fill(bodyPath, with: .radialGradient(
Gradient(stops: [.init(color: .clear, location: 0.6),
.init(color: Color.black.opacity(0.2), location: 1)]),
center: .zero, startRadius: iconR*0.15, endRadius: iconR*1.25
))
// Eyes
var eyeCtx = ctx; eyeCtx.clip(to: bodyPath)
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
for f in mEyeFrames(mH) {
guard f.visible else { continue }
var ec = eyeCtx; ec.translateBy(x: f.x, y: f.y); ec.scaleBy(x: f.fx, y: f.fy)
let hh = max(f.h, f.w*0.3)
var pill = Path()
pill.addRoundedRect(in: CGRect(x: -f.w/2, y: -hh/2, width: f.w, height: hh),
cornerSize: CGSize(width: min(f.w/2,hh/2), height: min(f.w/2,hh/2)))
ec.fill(pill, with: .color(ink))
}
// Front outfit
if seasonal != .none && seasonal != .auto {
drawOutfitFrontStatic(context: context, outfit: seasonal, H: mH,
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
roll: 0, morph: 0, isMini: false)
}
// AUTO badge at bottom
var badgeCtx = context
let badgeCY = iconCY + ry * 0.72
badgeCtx.translateBy(x: cx, y: badgeCY)
let bw: CGFloat = 14, bh: CGFloat = 6.5
var badge = Path()
badge.addRoundedRect(in: CGRect(x: -bw/2, y: -bh/2, width: bw, height: bh),
cornerSize: CGSize(width: bh/2, height: bh/2))
badgeCtx.fill(badge, with: .color(Color.black.opacity(0.60)))
badgeCtx.draw(Text("АВТО").font(.system(size: 4.2, weight: .semibold)).foregroundColor(.white),
at: .zero)
case .none:
var ctx = context
ctx.translateBy(x: cx, y: cy)
// Circle with diagonal slash (⊘)
var circle = Path()
circle.addEllipse(in: CGRect(x: -R * 0.82, y: -R * 0.82, width: R * 1.64, height: R * 1.64))
ctx.stroke(circle, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
var slash = Path()
slash.move(to: CGPoint(x: -R * 0.56, y: R * 0.56))
slash.addLine(to: CGPoint(x: R * 0.56, y: -R * 0.56))
ctx.stroke(slash, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
default:
// Small Mochi wearing the outfit
let iconR: CGFloat = 10.0
let rx = iconR * 1.14, ry = iconR * 0.88
let mH = MochiH(R: iconR, yaw: 0, pitch: 0)
let bodyPath = mochiOutfitPath(rx, ry)
let iconCY = cy + iconR * 0.62
// Draw outfit behind
drawOutfitBehindStatic(context: context, outfit: outfit, H: mH,
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
roll: 0, morph: 0, isMini: false)
// Draw body
var ctx = context
ctx.translateBy(x: cx, y: iconCY)
let pumpkin = outfit == .pumpkin
let top = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
let bot = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
ctx.fill(bodyPath, with: .linearGradient(
Gradient(colors: [top, bot]),
startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85),
endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9)
))
ctx.fill(bodyPath, with: .radialGradient(
Gradient(stops: [
.init(color: .clear, location: 0),
.init(color: .clear, location: 0.6),
.init(color: Color.black.opacity(0.2), location: 1)
]),
center: .zero, startRadius: iconR * 0.15, endRadius: iconR * 1.25
))
ctx.fill(bodyPath, with: .radialGradient(
Gradient(stops: [
.init(color: Color.white.opacity(0.55), location: 0),
.init(color: .clear, location: 1)
]),
center: CGPoint(x: rx * 0.34, y: -ry * 0.46),
startRadius: 0, endRadius: iconR * 0.42
))
// Draw eyes
var eyeCtx = ctx
eyeCtx.clip(to: bodyPath)
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
for f in mEyeFrames(mH) {
guard f.visible else { continue }
var ec = eyeCtx
ec.translateBy(x: f.x, y: f.y)
ec.scaleBy(x: f.fx, y: f.fy)
let hh = max(f.h, f.w * 0.3)
var pill = Path()
pill.addRoundedRect(
in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh),
cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2))
)
ec.fill(pill, with: .color(ink))
}
// Draw outfit front
drawOutfitFrontStatic(context: context, outfit: outfit, H: mH,
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
roll: 0, morph: 0, isMini: false)
}
}
// MARK: - Card background
struct CardBackground<Content: View>: View {
enum Wash { case red, green, pink, amber, cyan, indigo, soft }
let wash: Wash?
let content: (() -> Content)?
init(wash: Wash?, @ViewBuilder content: @escaping () -> Content) {
self.wash = wash
self.content = content
}
var washColor: Color {
switch wash {
case .red: return Color(hex: "#F4505E").opacity(0.55)
case .green: return Color(hex: "#34D399").opacity(0.5)
case .pink: return Color(hex: "#F472B6").opacity(0.55)
case .amber: return Color(hex: "#F5A524").opacity(0.42)
case .cyan: return Color(hex: "#22D3EE").opacity(0.38)
case .indigo: return Color(hex: "#6366F1").opacity(0.5)
case .soft: return Color.white.opacity(0.08)
case nil: return Color.clear
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "#141518"))
.overlay(
RadialGradient(
gradient: Gradient(stops: [
.init(color: washColor, location: 0),
.init(color: .clear, location: 0.7)
]),
center: UnitPoint(x: 0.5, y: 1.3),
startRadius: 0,
endRadius: 280
)
.clipShape(RoundedRectangle(cornerRadius: 20))
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.035), lineWidth: 1)
)
if let content = content {
content()
}
}
}
}
extension CardBackground where Content == EmptyView {
init(wash: Wash?) {
self.wash = wash
self.content = nil
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "#141518"))
.overlay(
RadialGradient(
gradient: Gradient(stops: [
.init(color: washColor, location: 0),
.init(color: .clear, location: 0.7)
]),
center: UnitPoint(x: 0.5, y: 1.3),
startRadius: 0,
endRadius: 280
)
.clipShape(RoundedRectangle(cornerRadius: 20))
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.035), lineWidth: 1)
)
}
}
}
// MARK: - Shared sub-components
struct AgentWho: View {
let task: AgentTask?
let label: String
var body: some View {
HStack(spacing: 7) {
if let task = task {
Circle().fill(Color(hex: task.color)).frame(width: 8, height: 8)
Text(task.name).font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
}
Text(label).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
}
}
}
struct CodeBlock: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 12, design: .monospaced))
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Color.white.opacity(0.07))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06)))
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(Color(hex: "#E8E9EC"))
}
}
struct ContextChip: View {
let context: PromptContext
@State private var glowing = false
var label: String {
switch context {
case .window(let app, _, let url):
if let url = url, let host = URL(string: url)?.host { return "\(app) · \(host)" }
return app
case .file(let name, _): return name
}
}
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(LinearGradient(colors: [Color(hex: "#FF6B5B"), Color(hex: "#F7B32B"), Color(hex: "#2DD4A7"), Color(hex: "#38BDF8"), Color(hex: "#A78BFA")], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 7, height: 7)
Text(label)
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#F1F2F4"))
}
.padding(.horizontal, 10).padding(.vertical, 4)
.background(Color.white.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(glowing ? 0.75 : 0), lineWidth: 1.5))
.scaleEffect(glowing ? 1.06 : 1.0)
.onAppear {
withAnimation(.spring(response: 0.25, dampingFraction: 0.55)) { glowing = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
withAnimation(.easeOut(duration: 0.3)) { glowing = false }
}
}
}
}
struct MailField: View {
let label: String
let placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 8) {
Text(label)
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#80858E"))
.frame(width: 44, alignment: .leading)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#F5F6F8"))
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
struct ShimmeringText: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text)
.foregroundStyle(
LinearGradient(
stops: [
.init(color: Color(hex: "#7c818a"), location: 0),
.init(color: .white, location: 0.4),
.init(color: Color(hex: "#7c818a"), location: 0.7)
],
startPoint: .leading,
endPoint: .trailing
)
)
}
}
struct ShimmerOverlay: View {
@State private var phase: CGFloat = 0.0
var body: some View {
LinearGradient(
stops: [
// Clamp all locations to [0,1] and keep them ordered
.init(color: .clear, location: max(0, phase - 0.3)),
.init(color: Color.white.opacity(0.6), location: max(0, min(1, phase))),
.init(color: .clear, location: min(1, phase + 0.3))
],
startPoint: .leading, endPoint: .trailing
)
.blendMode(.overlay)
.onAppear {
withAnimation(.linear(duration: 2.2).repeatForever(autoreverses: false)) {
phase = 1.3 // travels left→right, exits right edge cleanly
}
}
}
}
// MARK: - Button styles
struct PrimaryButton: View {
let title: String
let kbd: String?
let action: () -> Void
init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) {
self.title = title; self.kbd = kbd; self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 7) {
Text(title).font(.system(size: 12.5, weight: .medium))
if let k = kbd {
Text(k).font(.system(size: 10.5))
.padding(.horizontal, 4)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black.opacity(0.4)))
.opacity(0.55)
}
}
.padding(.horizontal, 13).padding(.vertical, 7)
.background(Color(hex: "#F5F6F8"))
.foregroundColor(Color(hex: "#0B0C0E"))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
struct SecondaryButton: View {
let title: String
let kbd: String?
let action: () -> Void
init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) {
self.title = title; self.kbd = kbd; self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 7) {
Text(title).font(.system(size: 12.5, weight: .medium))
if let k = kbd {
Text(k).font(.system(size: 10.5))
.padding(.horizontal, 4)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4)))
.opacity(0.55)
}
}
.padding(.horizontal, 13).padding(.vertical, 7)
.background(Color.white.opacity(0.09))
.foregroundColor(Color(hex: "#F1F2F4"))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
struct IconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.08))
.clipShape(Circle())
.scaleEffect(configuration.isPressed ? 0.94 : 1)
}
}
struct SendButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: 28, height: 28)
.background(Color(hex: "#F5F6F8"))
.clipShape(Circle())
.scaleEffect(configuration.isPressed ? 0.94 : 1)
}
}
// MARK: - Settings island view (Point 7)
struct SettingsIslandView: View {
@ObservedObject var state: AppState
private var claudeConnected: Bool {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let hooks = json["hooks"] as? [String: Any],
let ss = hooks["SessionStart"] as? [[String: Any]] else { return false }
return ss.contains { matcher in
(matcher["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("NotchBuddy") == true
} ?? false
}
}
private var apiConnected: Bool {
KeychainStore.shared.get("anthropic-api-key") != nil
}
var body: some View {
ZStack(alignment: .leading) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 10) {
// Sound row
HStack(spacing: 10) {
Toggle("", isOn: $state.soundEnabled)
.toggleStyle(.switch)
.labelsHidden()
.scaleEffect(0.75)
.frame(width: 44)
Text("Звук")
.font(.system(size: 12.5))
.foregroundColor(Color(hex: "#C5C8CD"))
Slider(value: $state.soundVolume, in: 0...0.2)
.frame(width: 72)
.opacity(state.soundEnabled ? 1 : 0.4)
}
// Auto-close row
HStack(spacing: 10) {
Image(systemName: "timer")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 16)
Text("Автозакрытие · \(Int(state.autoCloseInterval)) с")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#C5C8CD"))
Spacer()
HStack(spacing: 6) {
ForEach([10, 15, 30], id: \.self) { s in
Button("\(s) с") {
state.autoCloseInterval = Double(s)
}
.font(.system(size: 11))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(state.autoCloseInterval == Double(s) ? Color(hex: "#252830") : Color.clear)
.foregroundColor(state.autoCloseInterval == Double(s) ? Color(hex: "#F5F6F8") : Color(hex: "#6B7079"))
.clipShape(Capsule())
.buttonStyle(.plain)
}
}
}
// Connection status
HStack(spacing: 14) {
StatusBadge(label: "Claude Code", ok: claudeConnected)
StatusBadge(label: "API", ok: apiConnected)
Spacer()
Button("Настройки…") {
NotificationCenter.default.post(name: .openFullSettings, object: nil)
}
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#8E939C"))
.buttonStyle(.plain)
}
}
.padding(.leading, 84)
.padding(.trailing, 16)
.padding(.vertical, 14)
}
}
}
struct StatusBadge: View {
let label: String
let ok: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(ok ? Color(hex: "#22C55E") : Color(hex: "#F4505E"))
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
}
}
}
// MARK: - Color extension (lighten)
// MARK: - Chat-provider switch (used by AI pill buttons and ↗ action)
/// Mirrors ModelPickerView provider-chip tap: animates, fires surprised emote + "pop" sound,
/// then opens the chat view. No-op if provider is already selected (just opens chat).
@MainActor
func switchChatProvider(_ provider: ChatProvider) {
let state = AppState.shared
if provider != state.chatProvider {
withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
state.chatProvider = provider
}
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
SoundEngine.shared.play("pop")
}
state.view = .prompt
}
extension Color {
func lighter(by amount: Double) -> Color {
guard let components = NSColor(self).usingColorSpace(.sRGB) else { return self }
return Color(
red: min(1, Double(components.redComponent) + amount),
green: min(1, Double(components.greenComponent) + amount),
blue: min(1, Double(components.blueComponent) + amount)
)
}
}
/// Russian plural form picker: 1 вклад, 2 вклада, 5 вкладов.
fileprivate func ruPlural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String {
let n10 = abs(n) % 10, n100 = abs(n) % 100
if n10 == 1 && n100 != 11 { return one }
if (2...4).contains(n10) && !(12...14).contains(n100) { return few }
return many
}