import SwiftUI import DynamicNotchKit // MARK: - AgentNotchView struct AgentNotchView: View { @ObservedObject var tracker: AgentProgressTracker var onCopy: () -> Void var onStop: () -> Void var onClose: () -> Void var onNewConversation: (() -> Void)? = nil var onSendText: ((String) -> Void)? = nil var onContentSizeChanged: (() -> Void)? = nil private let maxHeight: CGFloat = 250 private let maxStreamingHeight: CGFloat = 120 @State private var textInput: String = "" @State private var scrollWorkItem: DispatchWorkItem? var body: some View { VStack(alignment: .leading, spacing: 0) { statusHeader if !tracker.streamingContent.isEmpty { streamingContentView } else if !tracker.isTextInputMode && tracker.mode == "thinking" && tracker.toolEntries.isEmpty { thinkingView } if !tracker.toolEntries.isEmpty { toolProgressList } if tracker.mode == "done" { doneView } else if tracker.mode == "error" { errorView } if tracker.isTextInputMode && tracker.mode != "thinking" && tracker.mode != "running" { textInputBar } } .frame(width: 340) .colorScheme(.dark) .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.mode) .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.toolEntries.count) .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.isTextInputMode) .onChange(of: tracker.mode) { _, _ in onContentSizeChanged?() } .onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() } .onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() } .onChange(of: tracker.streamingContent.isEmpty) { _, _ in onContentSizeChanged?() } } // MARK: - 状态头 private var statusHeader: some View { HStack(spacing: 6) { Image(systemName: statusIcon) .font(.system(size: 10)) .foregroundColor(statusColor) .opacity(tracker.mode == "thinking" ? 1 : (tracker.mode == "running" ? 0.6 : 0.4)) Text(tracker.summary) .font(.system(size: 11, weight: .medium)) .foregroundColor(.white.opacity(0.7)) Spacer() if tracker.mode == "running" || tracker.mode == "thinking" { Button("停止") { onStop() } .font(.system(size: 10)) .foregroundColor(.white.opacity(0.6)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.white.opacity(0.1)) .clipShape(Capsule()) .buttonStyle(.plain) } if tracker.mode != "thinking" && tracker.mode != "running", let onNew = onNewConversation { Button(action: onNew) { Image(systemName: "plus.bubble") .font(.system(size: 9, weight: .medium)) .foregroundColor(.white.opacity(0.5)) .frame(width: 20, height: 20) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("新建对话") .padding(.leading, 2) } Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) .foregroundColor(.white.opacity(0.5)) .frame(width: 20, height: 20) .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.leading, 2) } .padding(.horizontal, 14) .padding(.vertical, 8) } // MARK: - 思考中 private var thinkingView: some View { HStack(spacing: 6) { ProgressView() .scaleEffect(0.6) Text("正在分析你的问题...") .font(.system(size: 11)) .foregroundColor(.white.opacity(0.4)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 12) } // MARK: - 实时流式输出 private var streamingContentView: some View { let hasTools = !tracker.toolEntries.isEmpty return ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { Text(tracker.streamingContent) .font(.system(size: 12)) .foregroundColor(.white.opacity(0.85)) .lineSpacing(3) .frame(maxWidth: .infinity, alignment: .leading) .id("streaming_bottom") } .frame(height: hasTools ? maxStreamingHeight : nil) .frame(maxHeight: maxStreamingHeight) .padding(.horizontal, 14) .padding(.vertical, 6) .onChange(of: tracker.streamingContent) { _, _ in withAnimation { proxy.scrollTo("streaming_bottom", anchor: .bottom) } } } } // MARK: - 工具进度列表(可见 3 条,可滚动查看全部) private var toolProgressList: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { ForEach(Array(tracker.toolEntries.enumerated()), id: \.element.id) { idx, entry in compactToolRow(entry: entry) .id(entry.id) .transition( .asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) ) ) .zIndex(Double(-idx)) } } } .frame(maxHeight: 60) .clipped() .padding(.horizontal, 4) .onReceive(tracker.$toolEntries) { entries in // 并行工具调用防抖:0.1s 内连续出现只滚一次到底 scrollWorkItem?.cancel() let workItem = DispatchWorkItem { withAnimation { if let lastID = entries.last?.id { proxy.scrollTo(lastID, anchor: .bottom) } } } scrollWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } } } private func compactToolRow(entry: AgentProgressTracker.ToolEntry) -> some View { let hasResult = !entry.resultLines.isEmpty let isActive = !hasResult && entry.id == tracker.toolEntries.last?.id && tracker.mode == "running" return HStack(spacing: 4) { Circle() .fill(isActive ? Color.white : Color.white.opacity(0.3)) .frame(width: 5, height: 5) Text(entry.display) .font(.system(size: 10, design: .monospaced)) .foregroundColor(hasResult ? .white.opacity(0.6) : .white.opacity(0.85)) .lineLimit(1) .truncationMode(.tail) if isActive { Circle() .fill(.white.opacity(0.5)) .frame(width: 3, height: 3) .opacity(blinkingOpacity) } Spacer() } .padding(.horizontal, 10) .padding(.vertical, 4) } @State private var blinkToggle = false private var blinkingOpacity: Double { blinkToggle ? 1 : 0.2 } // MARK: - 完成视图 private var doneView: some View { EmptyView() } // MARK: - 错误视图 private var errorView: some View { Text("❌ \(tracker.errorMessage)") .font(.system(size: 12)) .foregroundColor(.red.opacity(0.8)) .padding(.horizontal, 14) .padding(.vertical, 10) } // MARK: - 辅助 private var statusIcon: String { switch tracker.mode { case "thinking": return "brain" case "running": return "gearshape.2" case "done": return "checkmark.circle" case "error": return "xmark.circle" default: return "circle" } } private var statusColor: Color { switch tracker.mode { case "thinking": return .blue case "running": return .orange case "done": return .green case "error": return .red default: return .gray } } // MARK: - 文本输入栏 private var textInputBar: some View { let isBusy = tracker.mode == "thinking" || tracker.mode == "running" return VStack(spacing: 0) { Divider() .overlay(.white.opacity(0.1)) .padding(.horizontal, 10) HStack(spacing: 8) { AppKitTextField( text: $textInput, placeholder: isBusy ? "等待回复中..." : "输入你的问题...", isEditable: !isBusy, onSubmit: { if !isBusy { sendText() } } ) .frame(height: 28) .opacity(isBusy ? 0.5 : 1) Button(action: { if !isBusy { sendText() } }) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 20)) .foregroundColor(!isBusy && !textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .blue : .white.opacity(0.2)) } .buttonStyle(.plain) .disabled(isBusy || textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(.horizontal, 14) .padding(.vertical, 8) } } private func sendText() { let text = textInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } onSendText?(text) textInput = "" } }