fbf90fd4fb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
662 lines
28 KiB
Swift
662 lines
28 KiB
Swift
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 {
|
||
let base: CGFloat = 240
|
||
let perMsg: CGFloat = 40
|
||
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
|
||
}
|
||
|
||
/// 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 ? 0 : 1)
|
||
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
|
||
|
||
CountdownBar(state: state, islandW: islandWidth)
|
||
|
||
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 == .prompt) ? 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 == .prompt ? chatPromptHeight : h
|
||
}
|
||
}
|
||
.onChange(of: state.chatHistory.count) { _, _ in
|
||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||
}
|
||
.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 == .prompt ? 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 {
|
||
// 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: - 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 {
|
||
ForEach(IslandView.allCases, 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 && 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)
|
||
.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
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
// Left: tab capsules
|
||
HStack(spacing: 5) {
|
||
TabButton(icon: "house.fill", view: .overview, state: state)
|
||
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: {
|
||
#if !APPSTORE
|
||
if state.promptContext == nil {
|
||
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
|
||
}
|
||
#endif
|
||
})
|
||
TabButton(icon: "plus", view: .upload, state: state)
|
||
}
|
||
.padding(.leading, 14)
|
||
|
||
Spacer()
|
||
|
||
// Right: plan pill (GitHub build, home view only) + action icons
|
||
HStack(spacing: 8) {
|
||
#if !APPSTORE
|
||
if (state.view == .overview || state.view == .empty) && state.showPlanInNotch && state.planRelayInstalled {
|
||
ClaudePlanHeaderPill(state: state)
|
||
}
|
||
if (state.view == .overview || state.view == .empty), let w = state.codexWeekly {
|
||
ChatGPTLimitPill(window: w)
|
||
}
|
||
#endif
|
||
HStack(spacing: 14) {
|
||
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)
|
||
|
||
Button(action: { state.soundEnabled.toggle() }) {
|
||
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|