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

740 lines
33 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
/// Top-level SwiftUI view rendered inside the 720×320 transparent panel.
/// The island is drawn at the top-center; everything else is transparent and click-through.
/// Note: drag-drop is handled at the AppKit level in IslandWindowController (FileDropNSView),
/// not in SwiftUI, to avoid interfering with SwiftUI hit-testing.
struct IslandRootView: View {
@EnvironmentObject var state: AppState
var body: some View {
ZStack(alignment: .top) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
IslandContainer(state: state)
.frame(maxWidth: .infinity, alignment: .center)
}
.ignoresSafeArea()
}
}
// MARK: - Island container
struct IslandContainer: View {
@ObservedObject var state: AppState
@State private var islandWidth: CGFloat = IslandConst.notchWidth
@State private var islandHeight: CGFloat = IslandConst.notchHeight
@State private var cornerRadius: CGFloat = IslandConst.roundedCorner
// topRadius > 0 → convex expanded corners; < 0 → concave ear cutouts
@State private var islandTopRadius: CGFloat = 0
@State private var greetNotif: Bool = false
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72)
private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
private var chatPromptHeight: CGFloat { state.chatIslandHeight }
/// Pixels the content must be pushed down to clear the concave ear transparent area.
/// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
private var earOffset: CGFloat { max(0, -islandTopRadius) }
var body: some View {
// Canvas active during drag-over (.upload), post-drop animation (.uploading),
// AND choose overlay (.choose) — canvas handles the full sequence through user action.
// Engine deactivates when user clicks a canvas choose button or navigates away.
let uploadActive = state.mode == .expanded
&& UploadSequenceEngine.shared.isActive
&& (state.view == .upload || state.view == .uploading || state.view == .choose)
let greetingActive = state.mode == .expanded && state.view == .greeting
return ZStack(alignment: .topLeading) {
// Black island shape
IslandShape(width: islandWidth, height: islandHeight,
cornerRadius: cornerRadius, topRadius: islandTopRadius)
.fill(Color.black)
// Content
if state.mode == .expanded {
if greetingActive {
// Greeting canvas: fixed 640-wide, centered by offset so x=320 aligns with island center
GreetingCanvasView(state: state)
.frame(width: IslandConst.expandedWidth, height: 150)
.offset(x: (islandWidth - IslandConst.expandedWidth) / 2)
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
cornerRadius: cornerRadius, topRadius: islandTopRadius))
.transition(.opacity)
} else if uploadActive {
ZStack(alignment: .topLeading) {
UploadCanvasView(state: state)
.frame(width: islandWidth, height: islandHeight)
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
cornerRadius: cornerRadius, topRadius: islandTopRadius))
// Header overlaid: canvas CARD_Y=42 aligns exactly with header bottom,
// matching normal view proportions (8pt top + 34pt header + card + 10pt bottom).
IslandHeader(state: state)
.frame(width: islandWidth, height: 34)
.offset(y: 8)
}
.transition(.opacity)
} else {
IslandContentView(state: state)
.frame(width: islandWidth, height: islandHeight - earOffset)
.offset(y: earOffset)
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
cornerRadius: cornerRadius, topRadius: islandTopRadius))
.transition(.opacity)
}
}
// Single BotPlacement — always alive in the view tree so spring animations
// fire from the current position (e.g. choose at 60,101) when canvas deactivates.
// Hidden during upload canvas or greeting (both draw their own Mochi).
BotPlacement(state: state, islandW: islandWidth, islandH: islandHeight)
// Keep idle animations inside the resting strip. Expanded views
// retain the panel's full height for particles and hands.
.mask(alignment: .topLeading) {
Rectangle().frame(width: islandWidth,
height: state.mode == .expanded ? 320 : islandHeight)
}
.opacity(uploadActive || greetingActive || (state.mode == .expanded && state.view == .terminal) ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
CountdownBar(state: state, islandW: islandWidth)
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) {
ChatResizeHandle(state: state)
.position(x: islandWidth / 2, y: islandHeight - 7)
.transition(.opacity)
}
Group {
if state.mode == .compact {
CompactMiniGrid(state: state)
.scaleEffect(IslandRestingLayout(width: islandWidth, height: islandHeight).miniGridScale)
.position(x: islandWidth - 40, y: islandHeight / 2)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: state.mode == .compact)
}
.frame(width: islandWidth, height: islandHeight, alignment: .topLeading)
.onChange(of: state.mode) { oldMode, newMode in
let shrinking = modeOrder(newMode) < modeOrder(oldMode)
let anim = shrinking ? closeEase : openSpring
let (w, h) = islandSize(mode: newMode, view: state.view,
progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight)
let cr = newMode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
let tr: CGFloat = 0
withAnimation(anim) {
islandWidth = w
islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
cornerRadius = cr
islandTopRadius = tr
}
}
.onChange(of: state.view) { _, newView in
guard state.mode == .expanded else { return }
// Deactivate engine if user navigates outside the upload flow
let uploadViews: Set<IslandView> = [.upload, .uploading, .choose]
if UploadSequenceEngine.shared.isActive && !uploadViews.contains(newView) {
UploadSequenceEngine.shared.deactivate()
}
let (w, h) = islandSize(mode: .expanded, view: newView,
progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight)
withAnimation(openSpring) {
islandWidth = w
islandHeight = newView.isTall ? chatPromptHeight : h
}
}
.onChange(of: state.chatHistory.count) { _, _ in
guard state.mode == .expanded, state.view == .prompt else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatDragHeight) { _, h in
guard state.mode == .expanded, state.view.isTall, let h else { return }
islandHeight = h // follow the pointer, no spring
}
.onChange(of: state.chatDetent) { _, _ in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatWidth) { _, w in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandWidth = w }
}
.onAppear {
let (w, h) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight)
islandWidth = w
islandHeight = state.view.isTall ? chatPromptHeight : h
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
islandTopRadius = 0
}
.onReceive(NotificationCenter.default.publisher(for: .botGreet)) { _ in
greetNotif.toggle()
}
}
private func modeOrder(_ m: IslandMode) -> Int {
switch m { case .hidden: return 0; case .compact: return 1; case .expanded: return 2 }
}
}
// MARK: - Island shape
//
// topRadius > 0 → convex rounded top corners (expanded mode)
// topRadius < 0 → concave ear cutouts, |topRadius| = ear radius (compact/notch mode)
// topRadius = 0 → sharp top corners (transient during animation)
struct IslandShape: Shape {
var width: CGFloat
var height: CGFloat
var cornerRadius: CGFloat // bottom corners
var topRadius: CGFloat // see above
var animatableData: AnimatablePair<AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat>, CGFloat> {
get { .init(.init(.init(width, height), cornerRadius), topRadius) }
set {
width = newValue.first.first.first
height = newValue.first.first.second
cornerRadius = newValue.first.second
topRadius = newValue.second
}
}
func path(in rect: CGRect) -> Path {
let cr = max(0, cornerRadius)
var p = Path()
if topRadius >= 0 {
// ── Convex rounded top corners (expanded) ──────────────────────────
let tr = min(topRadius, min(width / 2, height / 2))
p.move(to: CGPoint(x: tr, y: 0))
p.addLine(to: CGPoint(x: width - tr, y: 0))
// Top-right convex corner
p.addArc(center: CGPoint(x: width - tr, y: tr), radius: tr,
startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false)
// Right edge
p.addLine(to: CGPoint(x: width, y: height - cr))
// Bottom-right corner
p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr,
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
// Bottom edge
p.addLine(to: CGPoint(x: cr, y: height))
// Bottom-left corner
p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr,
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
// Left edge
p.addLine(to: CGPoint(x: 0, y: tr))
// Top-left convex corner
p.addArc(center: CGPoint(x: tr, y: tr), radius: tr,
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
} else {
// ── Concave ear cutouts (compact / notch) ─────────────────────────
let er = -topRadius // positive ear radius
p.move(to: CGPoint(x: 0, y: 0))
// Top-left ear
p.addArc(center: CGPoint(x: 0, y: er), radius: er,
startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false)
// Top edge
p.addLine(to: CGPoint(x: width - er, y: er))
// Top-right ear
p.addArc(center: CGPoint(x: width, y: er), radius: er,
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
// Right edge
p.addLine(to: CGPoint(x: width, y: height - cr))
// Bottom-right corner
p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr,
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
// Bottom edge
p.addLine(to: CGPoint(x: cr, y: height))
// Bottom-left corner
p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr,
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
// Left edge back to top-left corner
p.addLine(to: CGPoint(x: 0, y: 0))
}
p.closeSubpath()
return p
}
}
// MARK: - Bot placement helper
struct BotPlacement: View {
@ObservedObject var state: AppState
let islandW: CGFloat
let islandH: CGFloat
var body: some View {
let (cx, cy, diameter, opacity) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: islandH, uploadProgress: state.uploadProgress, hasNotch: state.hasNotch)
let canvasSize = diameter / 0.6
let overhang: CGFloat = 40
let isUploading = state.view == .uploading
Group {
// No glow in uploading mode — the tiny dot doesn't need it
if state.mode == .expanded && !isUploading {
Circle()
.fill(RadialGradient(
gradient: Gradient(stops: [
.init(color: botGlowColor(state.effectiveState), location: 0),
.init(color: .clear, location: 0.62)
]),
center: .center,
startRadius: 0,
endRadius: diameter * 1.1
))
.frame(width: diameter * 2.2, height: diameter * 2.2)
.blur(radius: 6)
.opacity(botGlowOpacity(state.effectiveState))
.position(x: cx, y: cy)
.animation(.easeInOut(duration: 0.4), value: state.effectiveState)
}
// Uploading: no particle overhang (no hearts during upload), positioned directly at cy.
// BotEngine cy = H/2 + 0 + oy*R + R*0.06 ≈ H/2 (body centered in canvas).
// With .position(x:y:) placing the frame center at (uploadCx, cy), bot is at cy ✓.
//
// Normal: extra 40pt canvas at top for heart particles; position offset up by 20pt;
// BotEngine compensates with cy = H/2 + particleOverhang/2 + oy*R + R*0.06.
if isUploading {
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))
// cx = 36 + 526*t: bot center at fill right edge (bar left=36, width=526)
let uploadCx = 36 + CGFloat(t * (2 - t)) * 526
BotCanvasView(state: state, particleOverhang: 0)
.frame(width: canvasSize, height: canvasSize)
.opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity)
.position(x: uploadCx, y: cy)
}
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
} else {
BotCanvasView(state: state, particleOverhang: overhang)
.frame(width: canvasSize, height: canvasSize + overhang)
.opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity)
.position(x: cx, y: cy - overhang / 2)
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: cx)
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: cy)
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: canvasSize)
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
}
}
// Branch switch (uploading ↔ normal) animates with a fast spring: uploading dot
// scales out at bar-end while normal bot scales in at choose position.
.animation(.spring(response: 0.36, dampingFraction: 0.72), value: isUploading)
// Slap, drag, and hover are handled by the AppKit NSEvent monitor in
// IslandWindowController — not SwiftUI gestures — so this is safe.
.allowsHitTesting(false)
}
private func botGlowColor(_ s: BotState) -> Color {
switch s {
case .working: return Color(hex: "#3B9EFF")
case .thinking: return Color(hex: "#A78BFA")
case .searching: return Color(hex: "#6366F1")
case .approval: return Color(hex: "#F5A524")
case .error: return Color(hex: "#F4505E")
case .finished: return Color(hex: "#34D399")
case .ratelimit: return Color(hex: "#F59E0B")
default: return Color.white
}
}
private func botGlowOpacity(_ s: BotState) -> Double {
switch s {
case .idle, .sleeping: return 0.15
case .dizzy: return 0.0
default: return 0.65
}
}
}
func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH: CGFloat, uploadProgress: Double, hasNotch: Bool = true) -> (CGFloat, CGFloat, CGFloat, Double) {
let resting = IslandRestingLayout(width: islandW, height: islandH)
switch mode {
case .hidden:
return hasNotch ? (46, 16, 6, 0)
: (islandW / 2, resting.botCenterY, resting.botDiameter, 1)
case .compact: return (40, resting.botCenterY, resting.botDiameter, 1)
case .expanded:
let layout = IslandConst.viewLayouts[view]!
let diameter = layout.botDiameter
// Uploading: Mochi dot rides the leading edge of the progress fill.
// Bar in island coords: left=36, width=526. cx = 36 + progress*526 (dot center at fill right edge).
// cy comes from ViewLayout.botY (bar center in island coords).
if view == .uploading {
let cx = 36 + CGFloat(uploadProgress) * 526
return (cx, layout.botY ?? 103, diameter, 1)
}
let cx = layout.botX
let cy: CGFloat
if let fixedY = layout.botY {
cy = fixedY
} else if view == .prompt {
// Tall chat: keep the character near the top instead of mid-screen.
let headerBottom: CGFloat = 42
let cardH: CGFloat = 84
cy = min(171, headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2)
} else {
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
let headerBottom: CGFloat = 42
let cardH: CGFloat = 84
cy = headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2
}
return (cx, cy, diameter, 1)
}
}
// MARK: - Chat resize handle
/// Thin pill at the bottom of the island. Drag down: the chat follows the pointer and
/// snaps to the nearest detent (fits messages, ⅓, ½, full screen). From the home view
/// it opens the chat first.
struct ChatResizeHandle: View {
@ObservedObject var state: AppState
@State private var startHeight: CGFloat? = nil
@State private var isHovered = false
var body: some View {
Capsule()
.fill(Color.white.opacity(isHovered || startHeight != nil ? 0.55 : 0.22))
.frame(width: isHovered ? 54 : 40, height: 4)
.frame(width: 160, height: 14)
.contentShape(Rectangle())
.onHover { h in
withAnimation(.easeOut(duration: 0.15)) { isHovered = h }
if h { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
.gesture(
DragGesture(minimumDistance: 2, coordinateSpace: .global)
.onChanged { g in
if startHeight == nil {
if state.view != .prompt {
state.chatDetent = 0
state.view = .prompt
}
startHeight = state.chatDetentHeight(state.chatDetent)
}
let h = (startHeight ?? 0) + g.translation.height
state.chatDragHeight = min(state.chatDetentHeight(3), max(state.chatDetentHeight(0), h))
}
.onEnded { g in
let target = (startHeight ?? 0) + g.predictedEndTranslation.height
let best = (0...3).min { abs(state.chatDetentHeight($0) - target) < abs(state.chatDetentHeight($1) - target) } ?? 0
state.chatDetent = best
state.chatDragHeight = nil
startHeight = nil
SoundEngine.shared.play("tick")
}
)
.help("Потяни, чтобы развернуть чат")
}
}
// MARK: - Countdown bar
struct CountdownBar: View {
@ObservedObject var state: AppState
let islandW: CGFloat
@State private var barWidth: CGFloat = 0
@State private var timer: Timer? = nil
var body: some View {
GeometryReader { _ in
Rectangle()
.fill(Color.white.opacity(0.35))
.frame(width: barWidth, height: 2)
.cornerRadius(2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
.onAppear { startTimer() }
.onDisappear { timer?.invalidate() }
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
updateBar()
}
}
private func updateBar() {
guard state.mode == .expanded && !state.isPinned else {
barWidth = 0
return
}
let autoClose = state.autoCloseInterval
let window = min(10.0, autoClose * 0.6)
let elapsed = Date.now.timeIntervalSince(state.lastActivity)
let remaining = autoClose - elapsed
if remaining < window {
barWidth = max(0, CGFloat(remaining / window) * 160)
} else {
barWidth = 0
}
}
}
// MARK: - Island content (header + views, only in expanded mode)
struct IslandContentView: View {
@ObservedObject var state: AppState
var body: some View {
VStack(spacing: 0) {
IslandHeader(state: state)
.frame(height: 34)
.opacity(state.view == .confused ? 0 : 1)
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
ZStack {
// Only the visible screen is built (plus the chat, to keep its draft and
// scroll): hidden screens used to keep their animations and timers running.
ForEach(IslandView.allCases.filter { $0 == state.view || $0 == .prompt }, id: \.self) { v in
let active = state.view == v
// Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so
// it doesn't push the ZStack taller when inactive.
let isTall = v == .prompt || ((v == .mail || v == .terminal) && active)
let anim: Animation = active
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
: .easeIn(duration: 0.16)
IslandViewContent(view: v, state: state)
.frame(maxWidth: .infinity)
.frame(height: isTall ? nil : 98)
.frame(minHeight: (isTall && !active) ? 0 : nil, maxHeight: isTall ? .infinity : nil)
.opacity(active ? 1 : 0)
.scaleEffect(active ? 1 : 0.97)
.allowsHitTesting(active)
.transition(.opacity)
.animation(anim, value: state.view)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 10)
}
.padding(.top, 8)
.padding(.bottom, 10)
.foregroundColor(Color(hex: "#F5F6F8"))
}
}
// MARK: - Island header (tabs + icons)
struct IslandHeader: View {
@ObservedObject var state: AppState
private var leftTabs: [BroTab] { Array(state.tabs.prefix(3)) }
private var rightTabs: [BroTab] { Array(state.tabs.dropFirst(3)) }
var body: some View {
HStack(spacing: 0) {
// Left: tab capsules
// Tabs are split around the camera: three to its left, the rest to its right,
// with "+" right after the last tab.
HStack(spacing: 5) {
TabButton(icon: "house.fill", view: .overview, state: state)
ForEach(leftTabs) { TabChip(tab: $0, state: state) }
if rightTabs.isEmpty { NewTabButton(state: state) }
}
.padding(.leading, 14)
Spacer()
// Right: plan pill (GitHub build, home view only) + action icons
HStack(spacing: 8) {
if !rightTabs.isEmpty {
HStack(spacing: 5) {
ForEach(rightTabs) { TabChip(tab: $0, state: state) }
NewTabButton(state: state)
}
}
// Plan limits live inside the Claude / ChatGPT pills (AppGridView):
// here they ran under the camera.
HStack(spacing: 14) {
// Fold everything back into the notch (and the chat to its normal size).
Button(action: {
state.chatDragHeight = nil
state.chatDetent = 0
NotificationCenter.default.post(name: .islandCollapse, object: "user")
}) {
Image(systemName: "chevron.up")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 22, height: 22)
.background(Circle().fill(Color.white.opacity(state.chatDetent > 0 && state.view == .prompt ? 0.10 : 0)))
}
.buttonStyle(.plain)
.help("Свернуть в чёлку")
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
state.view = .settings
}
}) {
Image(systemName: state.view == .settings ? "gearshape.fill" : "gearshape")
.font(.system(size: 14))
.foregroundColor(state.view == .settings ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
// Networks (sound lives in Settings and on ⌃⌥M).
NetGlobeButton()
}
}
.padding(.trailing, 16)
}
.frame(maxHeight: .infinity)
}
}
struct TabButton: View {
let icon: String
let view: IslandView
@ObservedObject var state: AppState
var preAction: (() -> Void)? = nil
@State private var isHovered = false
private var isOn: Bool {
if view == .overview { return state.view == .overview || state.view == .empty }
return state.view == view
}
var body: some View {
Button(action: {
preAction?()
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
state.view = view
}
}) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundColor(isOn ? Color(hex: "#F5F6F8") : (isHovered ? Color(hex: "#B0B5BE") : Color(hex: "#8E939C")))
.frame(width: 30, height: 22)
.background(
isOn ? Color(hex: "#1D1F23") :
isHovered ? Color.white.opacity(0.07) : Color.clear
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
}
}
// MARK: - Claude Plan header pill (GitHub build only)
#if !APPSTORE
struct ClaudePlanHeaderPill: View {
@ObservedObject var state: AppState
@State private var isHovered = false
private var effectiveColor: String {
ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) })
}
private var label: String {
guard let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
guard let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() else {
return "Claude \(Int(pct.rounded()))%"
}
return "Claude \(Int(pct.rounded()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))"
}
private var isActive: Bool { state.showingPlanDetail || isHovered }
var body: some View {
Button(action: {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
state.showingPlanDetail.toggle()
}
}) {
HStack(spacing: 4) {
Circle()
.fill(Color(hex: effectiveColor))
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isActive
? Color(hex: effectiveColor).lighter(by: 0.3)
: Color(hex: "#6B7079"))
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(isActive
? Color(hex: effectiveColor).opacity(0.18)
: Color(hex: "#0E0F11"))
)
.overlay(
Capsule()
.stroke(Color(hex: effectiveColor).opacity(isActive ? 0.55 : 0.14), lineWidth: 1)
)
}
.buttonStyle(.plain)
.onHover { h in
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h }
}
}
}
#endif
/// ChatGPT weekly limit, from the Codex logs: "ChatGPT 23% (сброс пт 14:00)".
struct ChatGPTLimitPill: View {
let window: PlanWindow
var body: some View {
let pct = ClaudePlanGauge.effectivePct(window)
let color = ClaudePlanGauge.color(for: pct)
let reset = window.resetsAt > Date() ? " (сброс \(PlanTime.resetMoment(window.resetsAt)))" : ""
HStack(spacing: 4) {
Circle().fill(Color(hex: color)).frame(width: 6, height: 6)
Text("ChatGPT \(Int(pct.rounded()))%\(reset)")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079"))
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color(hex: "#0E0F11")))
.overlay(Capsule().stroke(Color(hex: color).opacity(0.14), lineWidth: 1))
.help("Недельный лимит ChatGPT (по логам Codex)")
}
}
// MARK: - Compact mini mochi grid (2×2 to the right of the notch)
struct CompactMiniGrid: View {
@ObservedObject var state: AppState
private var others: [AgentTask] {
Array(state.tasks.filter { $0.id != state.focusId }.prefix(4))
}
var body: some View {
let cols = [GridItem(.fixed(12), spacing: 4), GridItem(.fixed(12), spacing: 4)]
LazyVGrid(columns: cols, spacing: 4) {
ForEach(others) { task in
MiniBotCanvasView(task: task)
.frame(width: 12 / 0.6, height: 12 / 0.6)
.frame(width: 12, height: 12, alignment: .center)
}
}
.frame(width: 28, height: 28)
}
}