import SwiftUI // MARK: - Chat Panel View (Right Side, VSCode Copilot Style) struct ChatPanelView: View { @ObservedObject var viewModel: EditorViewModel private var agent: AgentService { viewModel.agentService } @State private var chatInput: String = "" @State private var showSessionList = false var body: some View { VStack(spacing: 0) { chatHeader Divider().background(VSC.border) modelSelector Divider().background(VSC.border) modeSelector Divider().background(VSC.border) if showSessionList { sessionList } else { chatMessages } chatInputBar } .frame(minWidth: 300) .background(VSC.sidebarBg) } // MARK: - Header private var chatHeader: some View { HStack(spacing: 8) { Image(systemName: "message.fill") .font(.system(size: 13)) .foregroundColor(VSC.accentBlue) Text("Copilot") .font(.system(size: 12, weight: .semibold)) .foregroundColor(VSC.text) Spacer() // Session list toggle Button(action: { showSessionList.toggle() }) { HStack(spacing: 3) { Image(systemName: showSessionList ? "bubble.left.fill" : "list.bullet") .font(.system(size: 10)) Text("\(agent.chatSessions.count)") .font(.system(size: 10)) } .foregroundColor(VSC.dimText) } .buttonStyle(.plain) // New chat Button(action: { agent.newChat() showSessionList = false }) { Image(systemName: "plus.bubble") .font(.system(size: 12)) .foregroundColor(VSC.dimText) } .buttonStyle(.plain) .help("New Chat") // Close panel Button(action: { viewModel.showChatPanel = false }) { Image(systemName: "xmark") .font(.system(size: 10, weight: .medium)) .foregroundColor(VSC.dimText) } .buttonStyle(.plain) .help("Close Chat") } .padding(.horizontal, 12) .padding(.vertical, 8) } // MARK: - Model Selector private var modelSelector: some View { VStack(spacing: 6) { // Provider row HStack(spacing: 4) { ForEach(AIProvider.allCases) { provider in providerButton(provider) } } .padding(.horizontal, 8) // Model dropdown Menu { ForEach(agent.selectedProvider.models) { model in Button(action: { agent.selectModel(model) }) { HStack { Text(model.name) if model == agent.selectedModel { Image(systemName: "checkmark") } Spacer() Text(model.contextLabel) .foregroundColor(.secondary) } } } } label: { HStack(spacing: 6) { Image(systemName: agent.selectedProvider.iconName) .font(.system(size: 10)) .foregroundColor(agent.selectedProvider.color) Text(agent.selectedModel.name) .font(.system(size: 11)) .foregroundColor(VSC.text) .lineLimit(1) Spacer() Text(agent.selectedModel.contextLabel) .font(.system(size: 9)) .foregroundColor(VSC.dimText) if agent.selectedModel.isLocal { Image(systemName: "desktopcomputer") .font(.system(size: 8)) .foregroundColor(.green) } else { Image(systemName: "cloud") .font(.system(size: 8)) .foregroundColor(.cyan) } Image(systemName: "chevron.down") .font(.system(size: 8)) .foregroundColor(VSC.dimText) } .padding(.horizontal, 8) .padding(.vertical, 5) .background(VSC.inputBg) .cornerRadius(4) .overlay(RoundedRectangle(cornerRadius: 4).stroke(VSC.inputBorder, lineWidth: 1)) } .menuStyle(.borderlessButton) .padding(.horizontal, 8) } .padding(.vertical, 6) } private func providerButton(_ provider: AIProvider) -> some View { Button(action: { agent.selectProvider(provider) }) { VStack(spacing: 2) { Image(systemName: provider.iconName) .font(.system(size: 11)) Text(provider.rawValue) .font(.system(size: 8)) } .frame(maxWidth: .infinity) .padding(.vertical, 4) .foregroundColor(agent.selectedProvider == provider ? .white : VSC.dimText) .background(agent.selectedProvider == provider ? provider.color.opacity(0.3) : Color.clear) .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(agent.selectedProvider == provider ? provider.color.opacity(0.6) : VSC.inputBorder, lineWidth: 1) ) } .buttonStyle(.plain) } // MARK: - Mode Selector private var modeSelector: some View { HStack(spacing: 4) { ForEach(ChatMode.allCases, id: \.self) { mode in Button(action: { agent.chatMode = mode }) { HStack(spacing: 4) { Image(systemName: mode.iconName) .font(.system(size: 10)) Text(mode.rawValue) .font(.system(size: 10, weight: .medium)) } .frame(maxWidth: .infinity) .padding(.vertical, 4) .foregroundColor(agent.chatMode == mode ? .white : VSC.dimText) .background(agent.chatMode == mode ? VSC.accentBlue.opacity(0.4) : Color.clear) .cornerRadius(4) } .buttonStyle(.plain) .help(mode.description) } } .padding(.horizontal, 8) .padding(.vertical, 6) } // MARK: - Session List private var sessionList: some View { ScrollView { LazyVStack(spacing: 2) { ForEach(agent.chatSessions) { session in sessionRow(session) } } .padding(6) } } private func sessionRow(_ session: ChatSession) -> some View { let isActive = session.id == agent.activeChatID return Button(action: { agent.selectChat(session) showSessionList = false }) { HStack(spacing: 8) { Image(systemName: "bubble.left") .font(.system(size: 10)) .foregroundColor(isActive ? VSC.accentBlue : VSC.dimText) VStack(alignment: .leading, spacing: 2) { Text(session.title) .font(.system(size: 11, weight: isActive ? .semibold : .regular)) .foregroundColor(VSC.text) .lineLimit(1) HStack(spacing: 4) { Text(session.model.name) .font(.system(size: 9)) Text("ยท") Text("\(session.messages.count) msgs") .font(.system(size: 9)) } .foregroundColor(VSC.dimText) } Spacer() } .padding(.horizontal, 8) .padding(.vertical, 6) .background(isActive ? VSC.listActive : Color.clear) .cornerRadius(4) .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { Button("Delete") { agent.deleteChat(session) } } } // MARK: - Chat Messages private var chatMessages: some View { Group { if let session = agent.activeChat { if session.messages.isEmpty { chatEmptyState } else { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(session.messages) { msg in chatMessageRow(msg) .id(msg.id) } } .padding(10) } .onChange(of: session.messages.count) { _, _ in if let last = session.messages.last { proxy.scrollTo(last.id, anchor: .bottom) } } .onChange(of: session.messages.last?.content) { _, _ in // Auto-scroll during streaming if let last = session.messages.last, last.isStreaming { proxy.scrollTo(last.id, anchor: .bottom) } } } } } else { chatEmptyState } } } private var chatEmptyState: some View { VStack(spacing: 12) { Spacer() Image(systemName: "bubble.left.and.bubble.right") .font(.system(size: 32)) .foregroundColor(VSC.dimText.opacity(0.3)) Text("Start a conversation") .font(.system(size: 13)) .foregroundColor(VSC.dimText) VStack(alignment: .leading, spacing: 4) { quickPrompt("Explain this code") quickPrompt("Find bugs and fix them") quickPrompt("Create a new Swift file") quickPrompt("Build the project") quickPrompt("Run the tests") } Spacer() } .frame(maxWidth: .infinity) } private func quickPrompt(_ text: String) -> some View { Button(action: { chatInput = text submitChat() }) { HStack(spacing: 6) { Image(systemName: "arrow.right.circle") .font(.system(size: 9)) Text(text) .font(.system(size: 11)) } .foregroundColor(VSC.accentBlue) .padding(.horizontal, 10) .padding(.vertical, 4) .background(VSC.accentBlue.opacity(0.1)) .cornerRadius(12) } .buttonStyle(.plain) } private func chatMessageRow(_ msg: ChatMessage) -> some View { HStack(alignment: .top, spacing: 8) { // Avatar ZStack { Circle() .fill(msg.role.color.opacity(0.2)) .frame(width: 24, height: 24) Image(systemName: msg.role.iconName) .font(.system(size: 11)) .foregroundColor(msg.role.color) } VStack(alignment: .leading, spacing: 4) { // Role + time HStack(spacing: 4) { Text(msg.role.rawValue.capitalized) .font(.system(size: 10, weight: .semibold)) .foregroundColor(msg.role.color) Text(msg.formattedTime) .font(.system(size: 9)) .foregroundColor(VSC.dimText) if msg.isStreaming { ProgressView() .scaleEffect(0.5) } } // Content โ€” render agent steps and text separately if msg.role == .assistant && hasAgentSteps(msg.content) { agentStepsView(msg.content) if let finalText = extractFinalText(from: msg.content), !finalText.isEmpty { Text(finalText) .font(.system(size: 12)) .foregroundColor(msg.isError ? VSC.errorFg : VSC.text) .textSelection(.enabled) } } else { Text(msg.content) .font(.system(size: 12, design: msg.role == .tool ? .monospaced : .default)) .foregroundColor(msg.isError ? VSC.errorFg : VSC.text) .textSelection(.enabled) } // Streaming cursor if msg.isStreaming { RoundedRectangle(cornerRadius: 1) .fill(VSC.accentBlue) .frame(width: 6, height: 14) .opacity(0.8) } // Tool results ForEach(msg.toolResults) { result in VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Image(systemName: "wrench.fill") .font(.system(size: 8)) .foregroundColor(.orange) Text(result.toolName) .font(.system(size: 9, weight: .semibold, design: .monospaced)) .foregroundColor(.orange) } Text(result.output.prefix(500)) .font(.system(size: 10, design: .monospaced)) .foregroundColor(result.isError ? VSC.errorFg : VSC.dimText) .lineLimit(10) } .padding(6) .background(VSC.inputBg) .cornerRadius(4) } // Applied badge or Apply button for assistant code if msg.role == .assistant && !msg.isStreaming && msg.content.contains("```") { if msg.autoApplied { HStack(spacing: 4) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 9)) Text("Applied") .font(.system(size: 10, weight: .medium)) } .foregroundColor(.green) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.green.opacity(0.1)) .cornerRadius(4) } else { Button(action: { let code = extractCode(from: msg.content) viewModel.applyAgentSuggestion(code) }) { HStack(spacing: 4) { Image(systemName: "doc.on.clipboard") .font(.system(size: 9)) Text("Apply to Editor") .font(.system(size: 10)) } .foregroundColor(VSC.accentBlue) .padding(.horizontal, 8) .padding(.vertical, 3) .background(VSC.accentBlue.opacity(0.1)) .cornerRadius(4) } .buttonStyle(.plain) } } } } .padding(8) .background(msg.role == .user ? VSC.listHover : Color.clear) .cornerRadius(6) } private func extractCode(from text: String) -> String { let parts = text.components(separatedBy: "```") guard parts.count >= 3 else { return text } let codeBlock = parts[1] let lines = codeBlock.components(separatedBy: "\n") if lines.count > 1 { return lines.dropFirst().joined(separator: "\n") } return codeBlock } // MARK: - Input Bar private var chatInputBar: some View { VStack(spacing: 0) { Divider().background(VSC.border) // Status indicator if agent.status != .idle { HStack(spacing: 6) { ProgressView() .scaleEffect(0.6) Text(agent.status.displayText) .font(.system(size: 10)) .foregroundColor(agent.status.color) Spacer() } .padding(.horizontal, 10) .padding(.vertical, 4) .background(VSC.inputBg) } HStack(spacing: 8) { // Mode icon Image(systemName: agent.chatMode.iconName) .font(.system(size: 11)) .foregroundColor(VSC.dimText) TextField(placeholderForMode, text: $chatInput) .textFieldStyle(.plain) .font(.system(size: 12)) .foregroundColor(.white) .onSubmit { submitChat() } // Send button Button(action: submitChat) { Image(systemName: "paperplane.fill") .font(.system(size: 12)) .foregroundColor(chatInput.trimmingCharacters(in: .whitespaces).isEmpty ? VSC.dimText : VSC.accentBlue) } .buttonStyle(.plain) .disabled(chatInput.trimmingCharacters(in: .whitespaces).isEmpty || agent.status != .idle) } .padding(.horizontal, 10) .padding(.vertical, 8) .background(VSC.sidebarBg) } } private var placeholderForMode: String { switch agent.chatMode { case .chat: return "Ask about your code..." case .agent: return "Create, edit, run, build..." case .edit: return "Describe the edit..." } } private func submitChat() { let text = chatInput.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { return } chatInput = "" viewModel.sendChatMessage(text) } // MARK: - Agent Step Rendering private func hasAgentSteps(_ content: String) -> Bool { content.contains("โœ… ") || content.contains("โŒ ") || content.contains("๐Ÿ”ง ") || content.contains("โณ ") } private func agentStepsView(_ content: String) -> some View { let lines = content.components(separatedBy: "\n") let stepLines = lines.filter { line in let t = line.trimmingCharacters(in: .whitespaces) return t.hasPrefix("โœ…") || t.hasPrefix("โŒ") || t.hasPrefix("๐Ÿ”ง") || t.hasPrefix("โณ") } return VStack(alignment: .leading, spacing: 3) { ForEach(Array(stepLines.enumerated()), id: \.offset) { _, line in let trimmed = line.trimmingCharacters(in: .whitespaces) HStack(spacing: 4) { if trimmed.hasPrefix("โœ…") { Image(systemName: "checkmark.circle.fill") .font(.system(size: 10)) .foregroundColor(.green) } else if trimmed.hasPrefix("โŒ") { Image(systemName: "xmark.circle.fill") .font(.system(size: 10)) .foregroundColor(.red) } else if trimmed.hasPrefix("๐Ÿ”ง") { Image(systemName: "wrench.and.screwdriver.fill") .font(.system(size: 10)) .foregroundColor(.orange) } else { ProgressView() .scaleEffect(0.5) } Text(trimmed.dropFirst(2).trimmingCharacters(in: .whitespaces)) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(VSC.text) } } } .padding(8) .background(VSC.inputBg.opacity(0.5)) .cornerRadius(6) } private func extractFinalText(from content: String) -> String? { let lines = content.components(separatedBy: "\n") let nonStepLines = lines.filter { line in let t = line.trimmingCharacters(in: .whitespaces) return !t.hasPrefix("โœ…") && !t.hasPrefix("โŒ") && !t.hasPrefix("๐Ÿ”ง") && !t.hasPrefix("โณ") } let result = nonStepLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) return result.isEmpty ? nil : result } }