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 = [.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, 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.showPlanInNotch && state.planRelayInstalled { ClaudePlanHeaderPill(state: state) } #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 —" } return "Claude \(Int(pct.rounded()))%" } 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 // 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) } }