MioIsland/ClaudeIsland/UI/Views/NotchView.swift
xmqywx 5deeaffc8a refactor(plugins): remove JSON-based plugin system
Replace with native .bundle plugin architecture in next commits.
Old declarative system (themes/buddy/sound JSON) removed entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:21:18 +08:00

1255 lines
50 KiB
Swift

//
// NotchView.swift
// ClaudeIsland
//
// The main dynamic island SwiftUI view with accurate notch shape
//
import AppKit
import Combine
import CoreGraphics
import SwiftUI
// Corner radius constants
private let cornerRadiusInsets = (
opened: (top: CGFloat(19), bottom: CGFloat(24)),
closed: (top: CGFloat(6), bottom: CGFloat(14))
)
struct NotchView: View {
@ObservedObject var viewModel: NotchViewModel
@StateObject private var sessionMonitor = ClaudeSessionMonitor()
@StateObject private var activityCoordinator = NotchActivityCoordinator.shared
@State private var previousPendingIds: Set<String> = []
@State private var previousWaitingForInputIds: Set<String> = []
@State private var previousWaitingForQuestionIds: Set<String> = []
@State private var waitingForInputTimestamps: [String: Date] = [:] // sessionId -> when it entered waitingForInput
@State private var isVisible: Bool = false
@State private var isHovering: Bool = false
@State private var isBouncing: Bool = false
@State private var autoCollapseTimer: DispatchWorkItem? = nil
/// Track previous phases to detect transitions from working states to waitingForInput
@State private var previousPhases: [String: SessionPhase] = [:]
@AppStorage("smartSuppression") private var smartSuppression: Bool = true
@AppStorage("autoCollapseOnMouseLeave") private var autoCollapseOnMouseLeave: Bool = true
@AppStorage("compactCollapsed") private var compactCollapsed: Bool = false
@ObservedObject private var notchStore: NotchCustomizationStore = .shared
@Namespace private var activityNamespace
/// Whether any Claude session is currently processing or compacting
private var isAnyProcessing: Bool {
sessionMonitor.instances.contains { $0.phase == .processing || $0.phase == .compacting }
}
/// Whether any Claude session has a pending permission request
private var hasPendingPermission: Bool {
sessionMonitor.instances.contains { $0.phase.isWaitingForApproval }
}
/// Whether any Claude session is waiting for user input (done/ready state) within the display window
private var hasWaitingForInput: Bool {
let now = Date()
let displayDuration: TimeInterval = 30 // Show checkmark for 30 seconds
return sessionMonitor.instances.contains { session in
guard session.phase == .waitingForInput else { return false }
// Only show if within the 30-second display window
if let enteredAt = waitingForInputTimestamps[session.stableId] {
return now.timeIntervalSince(enteredAt) < displayDuration
}
return false
}
}
/// Whether any Claude session is waiting for a question answer
private var hasWaitingForQuestion: Bool {
sessionMonitor.instances.contains { $0.phase.isWaitingForQuestion }
}
/// Whether there are any active (non-ended) sessions
private var hasActiveSessions: Bool {
sessionMonitor.instances.contains { $0.phase != .ended }
}
/// The most urgent animation state across all active sessions.
/// Priority: needsYou > error > working > thinking > done > idle
private var mostUrgentAnimationState: AnimationState {
var best: AnimationState = .idle
for session in sessionMonitor.instances {
let state = session.phase.animationState
if animationPriority(state) > animationPriority(best) {
best = state
}
}
return best
}
/// Priority ordering for animation states (higher = more urgent)
private func animationPriority(_ state: AnimationState) -> Int {
switch state {
case .idle: return 0
case .done: return 1
case .thinking: return 2
case .working: return 3
case .error: return 4
case .needsYou: return 5
}
}
/// The highest-priority session: urgent states first, then most recently active
private var highestPrioritySession: SessionState? {
sessionMonitor.instances
.filter { $0.phase != .ended }
.max { a, b in
let pa = animationPriority(a.phase.animationState)
let pb = animationPriority(b.phase.animationState)
if pa != pb { return pa < pb }
return a.lastActivity < b.lastActivity
}
}
/// Split text into project name and status for separate styling
private var activityTextParts: (project: String, status: String)? {
guard let session = highestPrioritySession else { return nil }
let project = session.projectName
switch session.phase {
case .processing:
let status = session.lastToolName ?? L10n.working
return (project, status)
case .waitingForApproval:
let status = session.pendingToolName.map { L10n.approveWhat($0) } ?? L10n.needsApproval
return (project, status)
case .waitingForQuestion:
return (project, "Needs answer")
case .waitingForInput:
// Show smart summary when available, otherwise fall back to "done"
let status = session.smartSummary ?? L10n.done
return (project, status)
case .compacting:
return (project, L10n.compacting)
case .idle:
return (project, L10n.idle)
case .ended:
return nil
}
}
// MARK: - Sizing
private var closedNotchSize: CGSize {
CGSize(
width: viewModel.deviceNotchRect.width,
height: viewModel.deviceNotchRect.height
)
}
/// Extra width for expanding activities (like Dynamic Island).
///
/// Reads from `notchStore.customization.maxWidth` so the live edit
/// "resize" arrow buttons visibly grow / shrink the notch as the
/// user drives the slider. The user's `maxWidth` is the total
/// closed-with-content width subtracting the hardware notch
/// width yields the wing expansion.
///
/// Compact mode caps at 100pt regardless of the user's max so the
/// dot+icon+count layout never overflows the visible notch ring.
/// Full mode honors the user's max directly. Idle state (no
/// active sessions) is always 0 the notch shrinks tight around
/// the hardware shape.
private var expansionWidth: CGFloat {
guard hasActiveSessions else { return 0 }
let userMax = notchStore.customization.maxWidth
let userExpansion = max(0, userMax - closedNotchSize.width)
if compactCollapsed {
return min(100, userExpansion)
}
return userExpansion
}
private var notchSize: CGSize {
switch viewModel.status {
case .closed, .popping:
return closedNotchSize
case .opened:
return viewModel.openedSize
}
}
/// Width of the closed content (notch + any expansion)
private var closedContentWidth: CGFloat {
closedNotchSize.width + expansionWidth
}
// MARK: - Corner Radii
private var topCornerRadius: CGFloat {
viewModel.status == .opened
? cornerRadiusInsets.opened.top
: cornerRadiusInsets.closed.top
}
private var bottomCornerRadius: CGFloat {
viewModel.status == .opened
? cornerRadiusInsets.opened.bottom
: cornerRadiusInsets.closed.bottom
}
private var currentNotchShape: NotchShape {
NotchShape(
topCornerRadius: topCornerRadius,
bottomCornerRadius: bottomCornerRadius
)
}
// Animation springs
private let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
private let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
/// User-customized horizontal offset of the notch, clamped at
/// render time so an off-screen stored value on a smaller
/// secondary display never bleeds past the edge. Spec 5.5.
private var clampedHorizontalOffset: CGFloat {
NotchHardwareDetector.clampedHorizontalOffset(
storedOffset: notchStore.customization.horizontalOffset,
runtimeWidth: viewModel.status == .opened ? notchSize.width : closedContentWidth,
screenWidth: viewModel.screenRect.width
)
}
// MARK: - Body
var body: some View {
ZStack(alignment: .top) {
// Outer container does NOT receive hits - only the notch content does
VStack(spacing: 0) {
notchLayout
.notchPalette()
.frame(
maxWidth: viewModel.status == .opened ? notchSize.width : closedContentWidth,
alignment: .top
)
.padding(
.horizontal,
viewModel.status == .opened
? cornerRadiusInsets.opened.top
: cornerRadiusInsets.closed.bottom
)
.padding([.horizontal, .bottom], viewModel.status == .opened ? 12 : 0)
.background(NotchPalette.for(notchStore.customization.theme).bg)
.animation(.easeInOut(duration: 0.3), value: notchStore.customization.theme)
.clipShape(currentNotchShape)
.overlay(alignment: .top) {
Rectangle()
.fill(NotchPalette.for(notchStore.customization.theme).bg)
.frame(height: 1)
.padding(.horizontal, topCornerRadius)
.animation(.easeInOut(duration: 0.3), value: notchStore.customization.theme)
}
.shadow(
color: (viewModel.status == .opened || isHovering) ? .black.opacity(0.7) : .clear,
radius: 6
)
.frame(
maxWidth: viewModel.status == .opened ? notchSize.width : closedContentWidth,
maxHeight: viewModel.status == .opened ? notchSize.height : nil,
alignment: .top
)
.animation(viewModel.status == .opened ? openAnimation : closeAnimation, value: viewModel.status)
.animation(openAnimation, value: notchSize) // Animate container size changes between content types
.animation(.smooth, value: activityCoordinator.expandingActivity)
.animation(.smooth, value: hasActiveSessions)
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isBouncing)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) {
isHovering = hovering
}
// Auto-collapse on mouse leave (Task 2)
if hovering {
// Mouse re-entered: cancel pending auto-collapse
autoCollapseTimer?.cancel()
autoCollapseTimer = nil
} else if autoCollapseOnMouseLeave && viewModel.status == .opened {
// Mouse left: start 1.5s countdown unless waiting for approval or question
let hasApprovalPending = sessionMonitor.instances.contains { $0.phase.isWaitingForApproval }
let hasQuestionPending = sessionMonitor.instances.contains { $0.phase.isWaitingForQuestion }
if !hasApprovalPending && !hasQuestionPending {
let workItem = DispatchWorkItem { [self] in
if !isHovering && viewModel.status == .opened {
viewModel.notchClose()
}
}
autoCollapseTimer = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
}
}
.simultaneousGesture(
TapGesture().onEnded {
if viewModel.status != .opened {
viewModel.notchOpen(reason: .click)
}
}
)
.offset(x: clampedHorizontalOffset)
}
}
.opacity(isVisible ? 1 : 0)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.preferredColorScheme(.dark)
.onAppear {
sessionMonitor.startMonitoring()
// Non-notched devices need a visible target since there's no physical notch to hover over
if !viewModel.hasPhysicalNotch {
isVisible = true
}
}
.onChange(of: viewModel.status) { oldStatus, newStatus in
handleStatusChange(from: oldStatus, to: newStatus)
}
.onChange(of: sessionMonitor.pendingInstances) { _, sessions in
handlePendingSessionsChange(sessions)
}
.onChange(of: sessionMonitor.instances) { _, instances in
handleProcessingChange()
handleWaitingForInputChange(instances)
handleWaitingForQuestionChange(instances)
}
.onChange(of: expansionWidth) { _, newWidth in
viewModel.currentExpansionWidth = newWidth
}
.task {
// Sync the initial expansion width into the view model on
// first appearance so the hit-test region matches the
// visible notch from the very first frame.
viewModel.currentExpansionWidth = expansionWidth
}
}
// MARK: - Notch Layout
private var isProcessing: Bool {
activityCoordinator.expandingActivity.show && activityCoordinator.expandingActivity.type == .claude
}
/// Whether to show the expanded closed state (any active sessions)
private var showClosedActivity: Bool {
isProcessing || hasPendingPermission || hasWaitingForQuestion || hasWaitingForInput || hasActiveSessions
}
@ViewBuilder
private var notchLayout: some View {
VStack(alignment: .leading, spacing: 0) {
// Header row - hidden when opened, full when closed
headerRow
.frame(height: viewModel.status == .opened ? 4 : max(24, closedNotchSize.height))
// Main content only when opened
if viewModel.status == .opened {
contentView
.frame(width: notchSize.width - 24, alignment: .top) // Fixed width to prevent reflow
.transition(
.asymmetric(
insertion: .scale(scale: 0.8, anchor: .top)
.combined(with: .opacity)
.animation(.smooth(duration: 0.35)),
removal: .opacity.animation(.easeOut(duration: 0.15))
)
)
}
}
}
// MARK: - Header Row (persists across states)
@ViewBuilder
private var headerRow: some View {
HStack(spacing: 0) {
if viewModel.status == .opened {
// Opened state: invisible spacer only no icon
Color.clear
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: viewModel.status == .opened)
.frame(width: 1, height: 1)
} else if hasActiveSessions {
// Closed with sessions: Dynamic Island style content
CollapsedNotchContent(
sessions: sessionMonitor.instances,
mostUrgentState: mostUrgentAnimationState,
activityTextParts: activityTextParts,
notchHeight: closedNotchSize.height,
isBouncing: isBouncing,
activityNamespace: activityNamespace,
waitingForInputTimestamps: waitingForInputTimestamps,
compactMode: compactCollapsed,
hasPhysicalNotch: viewModel.hasPhysicalNotch,
notchWidth: closedNotchSize.width
)
.clipped()
} else {
// Closed without sessions: empty space
Rectangle()
.fill(.clear)
.frame(width: closedNotchSize.width - 20)
}
}
.frame(height: closedNotchSize.height)
.clipped()
}
// MARK: - Opened Header Content
@ViewBuilder
private var openedHeaderContent: some View {
HStack(spacing: 12) {
Spacer()
// Menu toggle
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
viewModel.toggleMenu()
}
} label: {
Image(systemName: viewModel.contentType == .menu ? "xmark" : "line.3.horizontal")
.notchFont(11, weight: .medium)
.notchSecondaryForeground()
.frame(width: 22, height: 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Content View (Opened State)
@ViewBuilder
private var contentView: some View {
Group {
switch viewModel.contentType {
case .instances:
ClaudeInstancesView(
sessionMonitor: sessionMonitor,
viewModel: viewModel
)
case .menu:
NotchMenuView(viewModel: viewModel)
case .chat(let session):
ChatView(
sessionId: session.sessionId,
initialSession: session,
sessionMonitor: sessionMonitor,
viewModel: viewModel
)
case .question(let session):
QuestionContentWrapper(
session: session,
sessionMonitor: sessionMonitor,
viewModel: viewModel
)
}
}
.frame(width: notchSize.width - 24) // Fixed width to prevent text reflow
// Removed .id() - was causing view recreation and performance issues
}
// MARK: - Event Handlers
private func handleProcessingChange() {
if hasActiveSessions {
// Show notch whenever there are active sessions
if isAnyProcessing || hasPendingPermission || hasWaitingForQuestion {
activityCoordinator.showActivity(type: .claude)
} else {
activityCoordinator.hideActivity()
}
isVisible = true
} else {
// Hide activity when no sessions
activityCoordinator.hideActivity()
// Delay hiding the notch until animation completes
// Non-notched devices stay visible (no physical anchor to hover back)
if viewModel.status == .closed && viewModel.hasPhysicalNotch {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if !hasActiveSessions && viewModel.status == .closed {
isVisible = false
}
}
}
}
}
private func handleStatusChange(from oldStatus: NotchStatus, to newStatus: NotchStatus) {
switch newStatus {
case .opened, .popping:
isVisible = true
// Clear waiting-for-input timestamps only when manually opened (user acknowledged)
if viewModel.openReason == .click || viewModel.openReason == .hover {
waitingForInputTimestamps.removeAll()
}
// If a session is waiting for a question, auto-show the question UI
// (handles the case where user closed notch accidentally and reopened)
if case .instances = viewModel.contentType,
let questionSession = sessionMonitor.instances.first(where: { $0.phase.isWaitingForQuestion }) {
viewModel.showQuestion(for: questionSession)
}
case .closed:
// Non-notched devices stay visible (no physical anchor to hover back)
guard viewModel.hasPhysicalNotch else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if viewModel.status == .closed && !hasActiveSessions && !activityCoordinator.expandingActivity.show {
isVisible = false
}
}
}
}
private func handlePendingSessionsChange(_ sessions: [SessionState]) {
let currentIds = Set(sessions.map { $0.stableId })
let newPendingIds = currentIds.subtracting(previousPendingIds)
if !newPendingIds.isEmpty &&
viewModel.status == .closed {
// Smart suppression: don't expand if user's terminal is frontmost
let termFront = TerminalVisibilityDetector.isTerminalFrontmost()
DebugLogger.log("Suppress", "[pending] newIds=\(newPendingIds.count) termFront=\(termFront)")
if smartSuppression && termFront {
DebugLogger.log("Suppress", "[pending] Suppressed — terminal frontmost")
} else {
DebugLogger.log("Suppress", "[pending] Opening notification")
viewModel.notchOpen(reason: .notification)
// If the pending session is AskUserQuestion, show the question UI
if let askSession = sessions.first(where: {
newPendingIds.contains($0.stableId) && $0.pendingToolName == "AskUserQuestion"
}) {
viewModel.showQuestion(for: askSession)
}
}
}
previousPendingIds = currentIds
}
private func handleWaitingForInputChange(_ instances: [SessionState]) {
// Get sessions that are now waiting for input
let waitingForInputSessions = instances.filter { $0.phase == .waitingForInput }
let currentIds = Set(waitingForInputSessions.map { $0.stableId })
let newWaitingIds = currentIds.subtracting(previousWaitingForInputIds)
// Track timestamps for newly waiting sessions
let now = Date()
for session in waitingForInputSessions where newWaitingIds.contains(session.stableId) {
waitingForInputTimestamps[session.stableId] = now
}
// Clean up timestamps for sessions no longer waiting
let staleIds = Set(waitingForInputTimestamps.keys).subtracting(currentIds)
for staleId in staleIds {
waitingForInputTimestamps.removeValue(forKey: staleId)
}
// Bounce the notch when a session newly enters waitingForInput state
if !newWaitingIds.isEmpty {
// Get the sessions that just entered waitingForInput
let newlyWaitingSessions = waitingForInputSessions.filter { newWaitingIds.contains($0.stableId) }
// Play notification sound if the session is not actively focused
if let soundName = AppSettings.notificationSound.soundName {
// Check if we should play sound (async check for tmux pane focus)
Task {
let shouldPlaySound = await shouldPlayNotificationSound(for: newlyWaitingSessions)
if shouldPlaySound {
await MainActor.run {
NSSound(named: soundName)?.play()
}
}
}
}
// Trigger bounce animation to get user's attention
DispatchQueue.main.async {
isBouncing = true
// Bounce back after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isBouncing = false
}
}
// Auto-popup: if a session transitioned FROM processing/compacting TO waitingForInput,
// expand the notch and show that session's chat after a 1-second delay
let sessionsFromWorkingState = newlyWaitingSessions.filter { session in
guard let prevPhase = previousPhases[session.stableId] else { return false }
return prevPhase == .processing || prevPhase == .compacting
}
if !sessionsFromWorkingState.isEmpty && viewModel.status == .closed {
let completedSession = sessionsFromWorkingState[0]
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [self] in
guard viewModel.status == .closed else { return }
guard sessionMonitor.instances.contains(where: {
$0.stableId == completedSession.stableId && $0.phase == .waitingForInput
}) else { return }
// Suppress if the session's terminal is frontmost
let isFront = TerminalVisibilityDetector.isSessionTerminalFrontmost(completedSession)
DebugLogger.log("Suppress", "session=\(completedSession.projectName) isFront=\(isFront) termApp=\(completedSession.terminalApp ?? "nil")")
if isFront {
DebugLogger.log("Suppress", "Suppressed — user is looking at terminal")
return
}
DebugLogger.log("Suppress", "Opening notification popup")
viewModel.notchOpen(reason: .notification)
if let currentSession = sessionMonitor.instances.first(where: {
$0.stableId == completedSession.stableId
}) {
viewModel.showChat(for: currentSession)
}
}
}
// Schedule hiding the checkmark after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [self] in
// Trigger a UI update to re-evaluate hasWaitingForInput
handleProcessingChange()
}
}
// Update previous phases for all current instances
for instance in instances {
previousPhases[instance.stableId] = instance.phase
}
// Clean up phases for sessions that no longer exist
let currentStableIds = Set(instances.map { $0.stableId })
for key in previousPhases.keys where !currentStableIds.contains(key) {
previousPhases.removeValue(forKey: key)
}
previousWaitingForInputIds = currentIds
}
private func handleWaitingForQuestionChange(_ instances: [SessionState]) {
let questionSessions = instances.filter { $0.phase.isWaitingForQuestion }
let currentIds = Set(questionSessions.map { $0.stableId })
let newQuestionIds = currentIds.subtracting(previousWaitingForQuestionIds)
if !newQuestionIds.isEmpty {
// Only open question UI if not already showing one prevents UI swap
// that can cause accidental clicks when content changes under the cursor.
if case .question = viewModel.contentType {
DebugLogger.log("AskUser", "[question] newIds=\(newQuestionIds.count) — already showing question, skipping")
} else if let session = questionSessions.first(where: { newQuestionIds.contains($0.stableId) }) {
DebugLogger.log("AskUser", "[question] newIds=\(newQuestionIds.count) — opening question UI")
viewModel.notchOpen(reason: .notification)
viewModel.showQuestion(for: session)
// Bounce the notch to attract attention
DispatchQueue.main.async {
isBouncing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isBouncing = false
}
}
}
}
// If no sessions are waiting for question and we're currently showing question content, go back to instances
if currentIds.isEmpty, case .question = viewModel.contentType {
viewModel.contentType = .instances
}
previousWaitingForQuestionIds = currentIds
}
/// Determine if notification sound should play for the given sessions
/// Returns true if ANY session is not actively focused
private func shouldPlayNotificationSound(for sessions: [SessionState]) async -> Bool {
for session in sessions {
guard let pid = session.pid else {
// No PID means we can't check focus, assume not focused
return true
}
let isFocused = await TerminalVisibilityDetector.isSessionFocused(sessionPid: pid)
if !isFocused {
return true
}
}
return false
}
}
// MARK: - Animated Ellipsis
/// Cycles dots: `.` -> `..` -> `...` -> `.` -> ...
/// Used for "working" / "processing" status in the collapsed notch.
struct AnimatedEllipsis: View {
@State private var dotCount = 0
let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
var body: some View {
Text(String(repeating: ".", count: dotCount + 1))
.onReceive(timer) { _ in
dotCount = (dotCount + 1) % 3
}
}
}
// MARK: - Collapsed Notch Content (Dynamic Island Style)
/// Shows session dots + pixel character + rotating carousel text in the collapsed notch.
struct CollapsedNotchContent: View {
let sessions: [SessionState]
let mostUrgentState: AnimationState
let activityTextParts: (project: String, status: String)?
let notchHeight: CGFloat
let isBouncing: Bool
var activityNamespace: Namespace.ID
/// Timestamps when sessions entered waitingForInput, keyed by stableId
var waitingForInputTimestamps: [String: Date]
/// Compact mode: only show dot + icon + count, no text
var compactMode: Bool = false
/// Whether the device has a physical notch (camera housing)
var hasPhysicalNotch: Bool = true
/// Width of the physical notch (used for camera avoidance spacing)
var notchWidth: CGFloat = 200
// MARK: - Content Carousel
/// Current carousel slide index
@State private var carouselIndex: Int = 0
/// Timer that advances the carousel every 3 seconds
private let carouselTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
/// The total number of carousel slides
private let carouselSlideCount = 4
/// The most active session (highest priority, most recently active)
private var mostActiveSession: SessionState? {
sessions
.filter { $0.phase != .ended }
.max { a, b in a.lastActivity < b.lastActivity }
}
/// Whether the current status text represents a "working" state that should use animated dots
private var isWorkingStatus: Bool {
mostUrgentState == .working || mostUrgentState == .thinking
}
/// The status text label without trailing dots (for animated ellipsis pairing)
private var statusLabelWithoutDots: String? {
guard let parts = activityTextParts else { return nil }
var s = parts.status
// Strip trailing dots / ellipsis so AnimatedEllipsis can replace them
while s.hasSuffix("...") || s.hasSuffix("\u{2026}") {
if s.hasSuffix("...") {
s = String(s.dropLast(3))
} else {
s = String(s.dropLast(1))
}
}
while s.hasSuffix(".") {
s = String(s.dropLast(1))
}
return s
}
/// Duration string for the most active session (e.g. "27m")
private var durationString: String? {
guard let session = mostActiveSession else { return nil }
return SessionPhaseHelpers.timeAgo(session.createdAt)
}
/// Color for a session dot based on its phase
private func dotColor(for phase: SessionPhase) -> Color {
switch phase {
case .processing, .compacting:
return TerminalColors.green
case .waitingForApproval, .waitingForQuestion:
return TerminalColors.amber
case .waitingForInput:
return TerminalColors.blue
case .idle, .ended:
return Color.white.opacity(0.25)
}
}
/// Group sessions by project (cwd), preserving order
private var sessionsByProject: [[SessionState]] {
var groups: [[SessionState]] = []
var seen: [String: Int] = [:] // cwd -> group index
for session in sessions where session.phase != .ended {
if let idx = seen[session.cwd] {
groups[idx].append(session)
} else {
seen[session.cwd] = groups.count
groups.append([session])
}
}
return groups
}
/// Total number of active (non-ended) sessions
private var activeSessionCount: Int {
sessions.filter { $0.phase != .ended }.count
}
@State private var pulsePhase: Bool = false
@ObservedObject private var buddyReader = BuddyReader.shared
@AppStorage("usePixelCat") private var usePixelCat: Bool = false
@ObservedObject private var notchStore: NotchCustomizationStore = .shared
// MARK: - Unattended Task Alert
/// How long the oldest waitingForInput session has been unattended (seconds)
@State private var unattendedSeconds: TimeInterval = 0
/// Timer that checks every 5 seconds for unattended sessions
@State private var unattendedTimer: Timer? = nil
/// Compute the longest unattended duration from waitingForInput timestamps
private var longestUnattendedDuration: TimeInterval {
let now = Date()
var maxDuration: TimeInterval = 0
for session in sessions where session.phase == .waitingForInput {
if let enteredAt = waitingForInputTimestamps[session.stableId] {
let duration = now.timeIntervalSince(enteredAt)
maxDuration = max(maxDuration, duration)
}
}
return maxDuration
}
/// Whether any session has been unattended for >30 seconds
private var isUnattended: Bool {
unattendedSeconds > 30
}
/// Whether any session has been unattended for >60 seconds (stronger alert)
private var isUrgentlyUnattended: Bool {
unattendedSeconds > 60
}
/// Override status dot color when unattended
private var effectiveStatusDotColor: Color {
if isUrgentlyUnattended {
return Color(red: 0.94, green: 0.27, blue: 0.27) // red
} else if isUnattended {
return Color(red: 1.0, green: 0.6, blue: 0.2) // orange
}
return statusDotColor
}
/// Status dot color for the left wing
private var statusDotColor: Color {
switch mostUrgentState {
case .working: return Color(red: 0.4, green: 0.91, blue: 0.98) // cyan
case .needsYou: return Color(red: 0.96, green: 0.62, blue: 0.04) // amber
case .error: return Color(red: 0.94, green: 0.27, blue: 0.27) // red
case .done: return Color(red: 0.29, green: 0.87, blue: 0.5) // green
case .thinking: return Color(red: 0.7, green: 0.6, blue: 1.0) // purple
case .idle: return Color.white.opacity(0.3)
}
}
var body: some View {
HStack(spacing: 0) {
// Left wing (visible left of camera)
HStack(spacing: 4) {
// Status dot small, subtle
Circle()
.fill(effectiveStatusDotColor)
.frame(width: 6, height: 6)
.shadow(color: effectiveStatusDotColor.opacity(0.5), radius: 3)
.opacity(pulsePhase ? 1.0 : 0.5)
// Buddy icon honors the showBuddy preference.
if notchStore.customization.showBuddy {
if usePixelCat {
PixelCharacterView(state: mostUrgentState)
.scaleEffect(0.28)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
} else if let buddy = buddyReader.buddy {
EmojiPixelView(emoji: buddy.species.emoji, style: .wave)
.scaleEffect(0.30)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
} else {
PixelCharacterView(state: mostUrgentState)
.scaleEffect(0.28)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
}
}
// Carousel status text hidden in compact mode
if !compactMode {
carouselContent
.frame(height: 16)
.clipped()
}
}
.padding(.leading, 6)
// Camera avoidance: on notched devices, ensure content stays outside camera area
if hasPhysicalNotch {
Spacer(minLength: notchWidth * 0.35)
} else {
Spacer()
}
// Right wing (visible right of camera)
HStack(spacing: 4) {
// Project name hidden in compact mode
if !compactMode, let parts = activityTextParts {
Text(parts.project)
.notchFont(13, weight: .medium, design: .monospaced)
.notchSecondaryForeground()
.lineLimit(1)
}
if activeSessionCount > 0 {
Text("\u{00D7}\(activeSessionCount)")
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundColor(badgeColor)
}
}
.padding(.trailing, 6)
}
.onReceive(carouselTimer) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
carouselIndex = (carouselIndex + 1) % carouselSlideCount
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) {
pulsePhase = true
}
// Start unattended check timer (every 5 seconds)
unattendedTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
DispatchQueue.main.async {
unattendedSeconds = longestUnattendedDuration
}
}
}
.onDisappear {
unattendedTimer?.invalidate()
unattendedTimer = nil
}
}
// MARK: - Carousel Content
@ViewBuilder
private var carouselContent: some View {
let slide = carouselIndex % carouselSlideCount
Group {
switch slide {
case 0:
// Slide 0: Status text (current behavior) with animated ellipsis for working states
carouselStatusText
case 1:
// Slide 1: Task title / first user message of most active session
carouselTaskTitle
case 2:
// Slide 2: Last tool name + action (e.g. "Edit: middleware.ts")
carouselToolAction
case 3:
// Slide 3: Project name + duration (e.g. "CodeIsland \u{00B7} 27m")
carouselProjectDuration
default:
carouselStatusText
}
}
.transition(.push(from: .bottom))
.animation(.easeInOut(duration: 0.3), value: carouselIndex)
.id(slide) // Force view identity change for transition
}
@ViewBuilder
private var carouselStatusText: some View {
if isWorkingStatus, let label = statusLabelWithoutDots {
HStack(spacing: 0) {
Text(label)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
AnimatedEllipsis()
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
}
} else if let parts = activityTextParts {
Text(parts.status)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
}
}
@ViewBuilder
private var carouselTaskTitle: some View {
if let session = mostActiveSession,
let title = session.firstUserMessage ?? session.conversationInfo.summary {
let truncated = title.count > 24 ? String(title.prefix(24)) + "\u{2026}" : title
Text(truncated)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
} else {
// Fall back to status text if no task title available
carouselStatusText
}
}
/// Formatted tool action string for carousel slide 2
private var toolActionLabel: String? {
guard let session = mostActiveSession,
let toolName = session.lastToolName else { return nil }
if let msg = session.lastMessage {
let components = msg.components(separatedBy: CharacterSet(charactersIn: "/\\"))
let filename = components.last ?? msg
let short = filename.count > 18 ? String(filename.prefix(18)) + "\u{2026}" : filename
return "\(toolName): \(short)"
}
return toolName
}
@ViewBuilder
private var carouselToolAction: some View {
if let label = toolActionLabel {
Text(label)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
} else {
// Fall back to status text if no tool info
carouselStatusText
}
}
@ViewBuilder
private var carouselProjectDuration: some View {
if let session = mostActiveSession {
let duration = durationString ?? ""
let display = duration.isEmpty
? session.projectName
: "\(session.projectName) \u{00B7} \(duration)"
Text(display)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
} else if let parts = activityTextParts {
Text(parts.project)
.notchFont(13, weight: .medium, design: .monospaced)
.foregroundStyle(statusGradient)
.lineLimit(1)
}
}
/// Status text gradient based on state
private var statusGradient: LinearGradient {
switch mostUrgentState {
case .working:
return LinearGradient(colors: [Color(red:0.3,green:0.9,blue:0.95), Color(red:0.2,green:0.95,blue:0.5)], startPoint: .leading, endPoint: .trailing)
case .needsYou:
return LinearGradient(colors: [Color(red:1.0,green:0.75,blue:0.3), Color(red:1.0,green:0.55,blue:0.2)], startPoint: .leading, endPoint: .trailing)
case .error:
return LinearGradient(colors: [Color(red:1.0,green:0.4,blue:0.4), Color(red:0.9,green:0.2,blue:0.2)], startPoint: .leading, endPoint: .trailing)
case .thinking:
return LinearGradient(colors: [Color(red:0.7,green:0.6,blue:1.0), Color(red:0.5,green:0.8,blue:1.0)], startPoint: .leading, endPoint: .trailing)
case .done:
return LinearGradient(colors: [Color(red:0.3,green:0.87,blue:0.5), Color(red:0.2,green:0.8,blue:0.7)], startPoint: .leading, endPoint: .trailing)
case .idle:
return LinearGradient(colors: [.white.opacity(0.5), .white.opacity(0.3)], startPoint: .leading, endPoint: .trailing)
}
}
/// Badge color based on most urgent state
private var badgeColor: Color {
switch mostUrgentState {
case .needsYou: return TerminalColors.amber
case .error: return Color(red: 0.94, green: 0.27, blue: 0.27)
case .working: return TerminalColors.green
case .thinking: return Color(red: 0.65, green: 0.55, blue: 0.98)
case .done: return TerminalColors.blue
case .idle: return Color.white.opacity(0.3)
}
}
/// Flatten sessions into dot entries with group separators, capped at max dots
private var dotEntries: [(session: SessionState, isLastInGroup: Bool)] {
let groups = sessionsByProject
let totalActive = activeSessionCount
let maxDots = totalActive > 8 ? 7 : min(totalActive, 8)
var entries: [(session: SessionState, isLastInGroup: Bool)] = []
for (groupIndex, group) in groups.enumerated() {
for (sessionIndex, session) in group.enumerated() {
guard entries.count < maxDots else { break }
let isLast = sessionIndex == group.count - 1 && groupIndex < groups.count - 1
entries.append((session: session, isLastInGroup: isLast))
}
guard entries.count < maxDots else { break }
}
return entries
}
@ViewBuilder
private var sessionDots: some View {
let totalActive = activeSessionCount
let showOverflow = totalActive > 8
let entries = dotEntries
HStack(spacing: 2) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
Circle()
.fill(dotColor(for: entry.session.phase))
.frame(width: 4, height: 4)
.padding(.trailing, entry.isLastInGroup ? 2 : 0)
}
if showOverflow {
Text("+\(totalActive - 7)")
.notchFont(11, weight: .medium, design: .monospaced)
.notchSecondaryForeground()
.padding(.leading, 1)
}
}
}
}
// MARK: - Scrolling Text View
/// Horizontally scrolling text for the collapsed notch.
/// If text fits, it stays static. If it overflows, it scrolls continuously.
struct ScrollingTextView: View {
let text: String
@State private var textWidth: CGFloat = 0
@State private var containerWidth: CGFloat = 0
@State private var offset: CGFloat = 0
private var needsScrolling: Bool {
textWidth > containerWidth && containerWidth > 0
}
var body: some View {
GeometryReader { geo in
let availableWidth = geo.size.width
Text(text)
.notchFont(13, weight: .regular, design: .monospaced)
.notchSecondaryForeground()
.lineLimit(1)
.background(
GeometryReader { textGeo in
Color.clear
.onAppear {
textWidth = textGeo.size.width
containerWidth = availableWidth
startScrollingIfNeeded()
}
.onChange(of: text) { _, _ in
textWidth = textGeo.size.width
containerWidth = availableWidth
offset = 0
startScrollingIfNeeded()
}
}
)
.offset(x: needsScrolling ? offset : 0)
}
.frame(height: 14)
.clipped()
}
private func startScrollingIfNeeded() {
guard needsScrolling else {
offset = 0
return
}
// Scroll from right edge to left, then reset
let scrollDistance = textWidth + 40 // extra gap before restart
let duration = Double(scrollDistance) / 30.0 // ~30pt/sec
// Reset to start position (text starts just off-screen right)
offset = containerWidth
withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
offset = -textWidth
}
}
}
// MARK: - Question Content Wrapper
/// Wrapper view that resolves QuestionContext from either .waitingForQuestion
/// or .waitingForApproval with AskUserQuestion tool, then shows AskUserQuestionView.
/// Extracted to a separate struct to avoid SwiftUI type-checker complexity in NotchView.
private struct QuestionContentWrapper: View {
let session: SessionState
@ObservedObject var sessionMonitor: ClaudeSessionMonitor
@ObservedObject var viewModel: NotchViewModel
var body: some View {
let liveSession = sessionMonitor.instances.first(where: { $0.sessionId == session.sessionId }) ?? session
if let ctx = Self.questionContext(for: liveSession) {
AskUserQuestionView(
session: liveSession,
context: ctx,
sessionMonitor: sessionMonitor
)
} else {
ClaudeInstancesView(
sessionMonitor: sessionMonitor,
viewModel: viewModel
)
.onAppear {
viewModel.contentType = .instances
}
}
}
static func questionContext(for session: SessionState) -> QuestionContext? {
if let ctx = session.phase.questionContext {
return ctx
}
if let permission = session.activePermission,
session.pendingToolName == "AskUserQuestion",
let input = permission.toolInput,
let questionsRaw = input["questions"]?.value as? [[String: Any]] {
let questions = questionsRaw.compactMap { q -> QuestionItem? in
guard let question = q["question"] as? String else { return nil }
let header = q["header"] as? String
let multiSelect = q["multiSelect"] as? Bool ?? false
let optionsRaw = q["options"] as? [[String: Any]] ?? []
let options = optionsRaw.compactMap { o -> QuestionOption? in
guard let label = o["label"] as? String else { return nil }
return QuestionOption(label: label, description: o["description"] as? String)
}
return QuestionItem(question: question, header: header, options: options, multiSelect: multiSelect)
}
guard !questions.isEmpty else { return nil }
return QuestionContext(
toolUseId: permission.toolUseId,
questions: questions,
receivedAt: permission.receivedAt
)
}
return nil
}
}