import ChatService import ComposableArchitecture import Foundation import ChatAPIService import Preferences import Terminal import ConversationServiceProvider import Persist import GitHubCopilotService import Logger import OrderedCollections import SwiftUI import GitHelper import SuggestionBasic import HostAppActivator public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { case user case assistant case ignored } public var id: String public var role: Role public var text: String public var imageReferences: [ImageReference] = [] public var references: [ConversationReference] = [] public var followUp: ConversationFollowUp? = nil public var suggestedTitle: String? = nil public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] public var editAgentRounds: [AgentRound] = [] public var parentTurnId: String? = nil public var panelMessages: [CopilotShowMessageParams] = [] public var codeReviewRound: CodeReviewRound? = nil public var fileEdits: [FileEdit] = [] public var turnStatus: ChatMessage.TurnStatus? = nil public let requestType: RequestType public var modelName: String? = nil public var billingMultiplier: Float? = nil public init( id: String, role: Role, text: String, imageReferences: [ImageReference] = [], references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], codeReviewRound: CodeReviewRound? = nil, fileEdits: [FileEdit] = [], turnStatus: ChatMessage.TurnStatus? = nil, requestType: RequestType, modelName: String? = nil, billingMultiplier: Float? = nil ) { self.id = id self.role = role self.text = text self.imageReferences = imageReferences self.references = references self.followUp = followUp self.suggestedTitle = suggestedTitle self.errorMessages = errorMessages self.steps = steps self.editAgentRounds = editAgentRounds self.parentTurnId = parentTurnId self.panelMessages = panelMessages self.codeReviewRound = codeReviewRound self.fileEdits = fileEdits self.turnStatus = turnStatus self.requestType = requestType self.modelName = modelName self.billingMultiplier = billingMultiplier } } private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } struct ChatContext: Equatable { var typedMessage: String var attachedReferences: [ConversationAttachedReference] var attachedImages: [ImageReference] init(typedMessage: String, attachedReferences: [ConversationAttachedReference] = [], attachedImages: [ImageReference] = []) { self.typedMessage = typedMessage self.attachedReferences = attachedReferences self.attachedImages = attachedImages } static func empty() -> ChatContext { .init(typedMessage: "", attachedReferences: [], attachedImages: []) } static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext { .init( typedMessage: message.text, attachedReferences: message.references.compactMap { guard let url = $0.url else { return nil } if $0.isDirectory { return .directory(.init(url: url)) } else { let relativePath = url.path.replacingOccurrences(of: projectURL.path, with: "") let fileName = url.lastPathComponent return .file(.init(url: url, relativePath: relativePath, fileName: fileName)) } }, attachedImages: message.imageReferences) } } struct ChatContextProvider: Equatable { var contextStack: [ChatContext] init(contextStack: [ChatContext] = []) { self.contextStack = contextStack } mutating func reset() { contextStack = [] } mutating func getNextContext() -> ChatContext? { guard !contextStack.isEmpty else { return nil } return contextStack.removeLast() } func getPreviousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { let previousUserMessage: DisplayedChatMessage? = { let userMessages = history.filter { $0.role == .user } guard !userMessages.isEmpty else { return nil } let stackCount = contextStack.count guard userMessages.count > stackCount else { return nil } let index = userMessages.count - stackCount - 1 guard index >= 0 else { return nil } return userMessages[index] }() var context: ChatContext? if let previousUserMessage { context = .from(previousUserMessage, projectURL: projectURL) } return context } mutating func pushContext(_ context: ChatContext) { contextStack.append(context) } } @Reducer struct Chat { public typealias MessageID = String public enum EditorMode: Hashable { case input // Default input mode case editUserMessage(MessageID) var isDefault: Bool { self == .input } var isEditingUserMessage: Bool { switch self { case .input: false case .editUserMessage: true } } var editingUserMessageId: String? { switch self { case .input: nil case .editUserMessage(let messageID): messageID } } } @ObservableState struct EditorState: Equatable { enum Field: String, Hashable { case textField case fileSearchBar } var codeReviewState = ConversationCodeReviewFeature.State() var mode: EditorMode var contexts: [EditorMode: ChatContext] var contextProvider: ChatContextProvider var focusedField: Field? var currentEditor: ConversationFileReference? var handOffClicked: Bool = false init( mode: EditorMode = .input, contexts: [EditorMode: ChatContext] = [.input: .empty()], contextProvider: ChatContextProvider = .init(), focusedField: Field? = nil, currentEditor: ConversationFileReference? = nil, handOffClicked: Bool = false ) { self.mode = mode self.contexts = contexts self.contextProvider = contextProvider self.focusedField = focusedField self.currentEditor = currentEditor self.handOffClicked = handOffClicked } func context(for mode: EditorMode) -> ChatContext { contexts[mode] ?? .empty() } mutating func setContext(_ context: ChatContext, for mode: EditorMode) { contexts[mode] = context } mutating func updateCurrentContext(_ update: (inout ChatContext) -> Void) { var context = self.context(for: mode) update(&context) setContext(context, for: mode) } mutating func keepOnlyInputContext() { let inputContext = context(for: .input) contexts = [.input: inputContext] } mutating func clearAttachedImages() { updateCurrentContext { $0.attachedImages.removeAll() } } mutating func addReference(_ reference: ConversationAttachedReference) { updateCurrentContext { context in guard !context.attachedReferences.contains(reference) else { return } context.attachedReferences.append(reference) } } mutating func removeReference(_ reference: ConversationAttachedReference) { updateCurrentContext { context in guard let index = context.attachedReferences.firstIndex(of: reference) else { return } context.attachedReferences.remove(at: index) } } mutating func addImage(_ image: ImageReference) { updateCurrentContext { context in guard !context.attachedImages.contains(image) else { return } context.attachedImages.append(image) } } mutating func removeImage(_ image: ImageReference) { updateCurrentContext { context in guard let index = context.attachedImages.firstIndex(of: image) else { return } context.attachedImages.remove(at: index) } } mutating func pushContext(_ context: ChatContext) { contextProvider.pushContext(context) } mutating func resetContextProvider() { contextProvider.reset() } mutating func popNextContext() -> ChatContext? { contextProvider.getNextContext() } func previousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? { contextProvider.getPreviousContext(from: history, projectURL: projectURL) } } @ObservableState struct ConversationState: Equatable { var history: [DisplayedChatMessage] var isReceivingMessage: Bool var requestType: RequestType? init( history: [DisplayedChatMessage] = [], isReceivingMessage: Bool = false, requestType: RequestType? = nil ) { self.history = history self.isReceivingMessage = isReceivingMessage self.requestType = requestType } func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { guard let index = history.firstIndex(where: { $0.id == messageId }) else { return [] } return Array(history[(index + 1)...]) } func editUserMessageEffectedMessages(for mode: EditorMode) -> [DisplayedChatMessage] { guard case .editUserMessage(let messageId) = mode else { return [] } return subsequentMessages(after: messageId) } } struct AgentEditingState: Equatable { var fileEditMap: OrderedDictionary var diffViewerController: DiffViewWindowController? init( fileEditMap: OrderedDictionary = [:], diffViewerController: DiffViewWindowController? = nil ) { self.fileEditMap = fileEditMap self.diffViewerController = diffViewerController } static func == (lhs: AgentEditingState, rhs: AgentEditingState) -> Bool { lhs.fileEditMap == rhs.fileEditMap && lhs.diffViewerController === rhs.diffViewerController } } struct EnvironmentState: Equatable { var isAgentMode: Bool var workspaceURL: URL? var selectedAgent: ConversationMode init( isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), workspaceURL: URL? = nil, selectedAgent: ConversationMode = .defaultAgent ) { self.isAgentMode = isAgentMode self.workspaceURL = workspaceURL self.selectedAgent = selectedAgent } } @ObservableState struct State: Equatable { typealias Field = EditorState.Field // Not use anymore. the title of history tab will get from chat tab info // Keep this var as `ChatTabItemView` reference this var title: String var editor: EditorState var conversation: ConversationState var agentEditing: AgentEditingState var environment: EnvironmentState var chatMenu: ChatMenu.State var codeReviewState: ConversationCodeReviewFeature.State init( title: String = "New Chat", editor: EditorState = .init(), conversation: ConversationState = .init(), agentEditing: AgentEditingState = .init(), environment: EnvironmentState = .init(), chatMenu: ChatMenu.State = .init(), codeReviewState: ConversationCodeReviewFeature.State = .init() ) { self.title = title self.editor = editor self.conversation = conversation self.agentEditing = agentEditing self.environment = environment self.chatMenu = chatMenu self.codeReviewState = codeReviewState } init( title: String = "New Chat", editorMode: EditorMode = .input, editorModeContexts: [EditorMode: ChatContext] = [.input: .empty()], focusedField: Field? = nil, history: [DisplayedChatMessage] = [], isReceivingMessage: Bool = false, requestType: RequestType? = nil, fileEditMap: OrderedDictionary = [:], diffViewerController: DiffViewWindowController? = nil, isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), workspaceURL: URL? = nil, selectedAgent: ConversationMode = .defaultAgent, chatMenu: ChatMenu.State = .init(), codeReviewState: ConversationCodeReviewFeature.State = .init() ) { self.init( title: title, editor: EditorState( mode: editorMode, contexts: editorModeContexts, focusedField: focusedField ), conversation: ConversationState( history: history, isReceivingMessage: isReceivingMessage, requestType: requestType ), agentEditing: AgentEditingState( fileEditMap: fileEditMap, diffViewerController: diffViewerController ), environment: EnvironmentState( isAgentMode: isAgentMode, workspaceURL: workspaceURL, selectedAgent: selectedAgent ), chatMenu: chatMenu, codeReviewState: codeReviewState ) } var editorMode: EditorMode { get { editor.mode } set { editor.mode = newValue if editor.contexts[newValue] == nil { editor.contexts[newValue] = .empty() } } } var chatContext: ChatContext { get { editor.context(for: editor.mode) } set { editor.setContext(newValue, for: editor.mode) } } var history: [DisplayedChatMessage] { get { conversation.history } set { conversation.history = newValue } } var isReceivingMessage: Bool { get { conversation.isReceivingMessage } set { conversation.isReceivingMessage = newValue } } var requestType: RequestType? { get { conversation.requestType } set { conversation.requestType = newValue } } var handOffClicked: Bool { get { editor.handOffClicked } set { editor.handOffClicked = newValue } } var focusedField: Field? { get { editor.focusedField } set { editor.focusedField = newValue } } var currentEditor: ConversationFileReference? { get { editor.currentEditor } set { editor.currentEditor = newValue } } var attachedReferences: [ConversationAttachedReference] { chatContext.attachedReferences } var attachedImages: [ImageReference] { chatContext.attachedImages } var typedMessage: String { get { chatContext.typedMessage } set { editor.updateCurrentContext { $0.typedMessage = newValue } editor.resetContextProvider() } } var fileEditMap: OrderedDictionary { get { agentEditing.fileEditMap } set { agentEditing.fileEditMap = newValue } } var diffViewerController: DiffViewWindowController? { get { agentEditing.diffViewerController } set { agentEditing.diffViewerController = newValue } } var isAgentMode: Bool { get { environment.isAgentMode } set { environment.isAgentMode = newValue } } var workspaceURL: URL? { get { environment.workspaceURL } set { environment.workspaceURL = newValue } } var selectedAgent: ConversationMode { get { environment.selectedAgent } set { environment.selectedAgent = newValue } } /// Not including the one being edited var editUserMessageEffectedMessages: [DisplayedChatMessage] { conversation.editUserMessageEffectedMessages(for: editor.mode) } // The following messages after check point message will hide on ChatPanel var pendingCheckpointMessageId: String? = nil // The chat context before the first restoring var pendingCheckpointContext: ChatContext? = nil var messagesAfterCheckpoint: [DisplayedChatMessage] { guard let pendingCheckpointMessageId, let index = history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) else { return [] } let nextIndex = index + 1 guard nextIndex < history.count else { return [] } // The order matters for restoring / redoing file edits return Array(history[nextIndex...]) } func getMessages(after afterMessageId: String, through throughMessageId: String?) -> [DisplayedChatMessage] { guard let afterMessageIdIndex = history.firstIndex(where: { $0.id == afterMessageId }) else { return [] } let startIndex = afterMessageIdIndex + 1 let endIndex: Int if let throughMessageId = throughMessageId, let throughMessageIdIndex = history.firstIndex(where: { $0.id == throughMessageId }) { endIndex = throughMessageIdIndex + 1 } else { endIndex = history.count } guard startIndex < endIndex, startIndex < history.count else { return [] } return Array(history[startIndex..) case appear case refresh case sendButtonTapped(String) case returnButtonTapped case updateTypedMessage(String) case setEditorMode(EditorMode) case stopRespondingButtonTapped case clearButtonTap case deleteMessageButtonTapped(MessageID) case resendMessageButtonTapped(MessageID) case setAsExtraPromptButtonTapped(MessageID) case focusOnTextField case referenceClicked(ConversationReference) case upvote(MessageID, ConversationRating) case downvote(MessageID, ConversationRating) case copyCode(MessageID) case insertCode(String) case toolCallAccepted(String) case toolCallAcceptedWithApproval(String, ToolAutoApprovalManager.AutoApproval?) case toolCallCompleted(String, String) case toolCallCancelled(String) case observeChatService case observeHistoryChange case observeIsReceivingMessageChange case observeFileEditChange case historyChanged case isReceivingMessageChanged case fileEditChanged case chatMenu(ChatMenu.Action) // File context case resetCurrentEditor case setCurrentEditor(ConversationFileReference) case addReference(ConversationAttachedReference) case removeReference(ConversationAttachedReference) // Image context case addSelectedImage(ImageReference) case removeSelectedImage(ImageReference) case followUpButtonClicked(String, String) case handOffButtonClicked(HandOff) // Agent File Edit case undoEdits(fileURLs: [URL]) case keepEdits(fileURLs: [URL]) case resetEdits case discardFileEdits(fileURLs: [URL]) case openDiffViewWindow(fileURL: URL) case setDiffViewerController(chat: StoreOf) case agentModeChanged(Bool) case selectedAgentChanged(ConversationMode) // Code Review case codeReview(ConversationCodeReviewFeature.Action) // Chat Context case reloadNextContext case reloadPreviousContext case resetContextProvider // External Action case observeFixErrorNotification case fixEditorErrorIssue(EditorErrorIssue) // Check Point case restoreCheckPoint(String) case restoreFileEdits case undoCheckPoint // Revert the restore case discardCheckPoint case reloadWorkingset(DisplayedChatMessage) case openAutoApproveSettings } let service: ChatService let id = UUID() enum CancelID: Hashable { case observeHistoryChange(UUID) case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) case observeFixErrorNotification(UUID) } @Dependency(\.openURL) var openURL @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool @AppStorage(\.chatResponseLocale) var chatResponseLocale var body: some ReducerOf { BindingReducer() Scope(state: \.chatMenu, action: /Action.chatMenu) { ChatMenu(service: service) } Scope(state: \.codeReviewState, action: /Action.codeReview) { ConversationCodeReviewFeature(service: service) } Reduce { state, action in switch action { case .appear: return .run { send in if isPreview { return } await send(.observeChatService) await send(.historyChanged) await send(.isReceivingMessageChanged) await send(.focusOnTextField) await send(.refresh) await send(.observeFixErrorNotification) let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() if let modes = await SharedChatService.shared.loadConversationModes(), let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { await send(.selectedAgentChanged(currentMode)) } let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange) for await _ in publisher.values { let isAgentMode = AppState.shared.isAgentModeEnabled() await send(.agentModeChanged(isAgentMode)) let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode() if let modes = await SharedChatService.shared.loadConversationModes(), let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) { await send(.selectedAgentChanged(currentMode)) } } } case .refresh: return .run { send in await send(.chatMenu(.refresh)) } case let .sendButtonTapped(id): guard !state.typedMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none } let message = state.typedMessage let skillSet = state.buildSkillSet( isCurrentEditorContextEnabled: enableCurrentEditorContext ) state.typedMessage = "" let selectedModel = AppState.shared.getSelectedModel() let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.supportVision ?? false let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : [] let references = state.attachedReferences state.editor.clearAttachedImages() let toDeleteMessageIds: [String] = { var messageIds: [String] = [] if state.editorMode.isEditingUserMessage { messageIds.append(contentsOf: state.editUserMessageEffectedMessages.map { $0.id }) if let editingUserMessageId = state.editorMode.editingUserMessageId { messageIds.append(editingUserMessageId) } } return messageIds }() return .run { send in await send(.resetContextProvider) await send(.discardCheckPoint) await service.deleteMessages(ids: toDeleteMessageIds) await send(.setEditorMode(.input)) try await service .send( id, content: message, contentImageReferences: attachedImages, skillSet: skillSet, references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallAccepted(toolCallId): guard !toolCallId.isEmpty else { return .none } return .run { _ in service.updateToolCallStatus(toolCallId: toolCallId, status: .accepted) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallAcceptedWithApproval(toolCallId, approval): guard !toolCallId.isEmpty else { return .none } return .run { send in if let approval { await ToolAutoApprovalManager.shared.approve(approval) } await send(.toolCallAccepted(toolCallId)) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallCancelled(toolCallId): guard !toolCallId.isEmpty else { return .none } return .run { _ in service.updateToolCallStatus(toolCallId: toolCallId, status: .cancelled) }.cancellable(id: CancelID.sendMessage(self.id)) case let .toolCallCompleted(toolCallId, result): guard !toolCallId.isEmpty else { return .none } return .run { _ in service.updateToolCallStatus(toolCallId: toolCallId, status: .completed, payload: result) }.cancellable(id: CancelID.sendMessage(self.id)) case let .followUpButtonClicked(id, message): guard !message.isEmpty else { return .none } let skillSet = state.buildSkillSet( isCurrentEditorContextEnabled: enableCurrentEditorContext ) let selectedModel = AppState.shared.getSelectedModel() let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.modelFamily let references = state.attachedReferences let agentMode = AppState.shared.isAgentModeEnabled() let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() return .run { send in await send(.resetContextProvider) await send(.discardCheckPoint) try await service .send( id, content: message, skillSet: skillSet, references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) case let .handOffButtonClicked(handOff): state.handOffClicked = true let agent = handOff.agent let prompt = handOff.prompt let shouldSend = handOff.send ?? false return .run { send in // Find and switch to the target agent let modes = await SharedChatService.shared.loadConversationModes() ?? [] if let targetAgent = modes.first(where: { $0.name.lowercased() == agent.lowercased() }) { await send(.selectedAgentChanged(targetAgent)) } // If send is true, send the prompt message if shouldSend && !prompt.isEmpty { await send(.updateTypedMessage(prompt)) let id = UUID().uuidString await send(.sendButtonTapped(id)) } else if !prompt.isEmpty { // Just populate the message field await send(.updateTypedMessage(prompt)) } } case .returnButtonTapped: state.typedMessage += "\n" return .none case let .updateTypedMessage(message): state.typedMessage = message return .none case let .setEditorMode(mode): switch mode { case .input: state.editorMode = mode // remove all edit contexts except input mode state.editor.keepOnlyInputContext() case .editUserMessage(let messageID): guard let message = state.history.first(where: { $0.id == messageID }), message.role == .user, let projectURL = service.getProjectRootURL() else { return .none } let chatContext: ChatContext = .from(message, projectURL: projectURL) state.editor.setContext(chatContext, for: mode) state.editorMode = mode let isReceivingMessage = service.isReceivingMessage return .run { send in if isReceivingMessage { await send(.stopRespondingButtonTapped) } } } return .none case .stopRespondingButtonTapped: return .merge( .run { _ in await service.stopReceivingMessage() }, .cancel(id: CancelID.sendMessage(id)) ) case .clearButtonTap: return .run { _ in await service.clearHistory() } case let .deleteMessageButtonTapped(id): return .run { _ in await service.deleteMessages(ids: [id]) } case let .resendMessageButtonTapped(id): return .run { _ in try await service.resendMessage(id: id) } case let .setAsExtraPromptButtonTapped(id): return .run { _ in await service.setMessageAsExtraPrompt(id: id) } case let .referenceClicked(reference): guard let fileURL = reference.url else { return .none } return .run { _ in if FileManager.default.fileExists(atPath: fileURL.path) { let terminal = Terminal() do { _ = try await terminal.runCommand( "/bin/bash", arguments: [ "-c", "xed -l 0 \"${TARGET_CHAT_FILE}\"", ], environment: [ "TARGET_CHAT_FILE": reference.filePath ] ) } catch { print(error) } } else if let url = URL(string: reference.uri), url.scheme != nil { await openURL(url) } } case .focusOnTextField: state.focusedField = .textField return .none case .observeChatService: return .run { send in await send(.observeHistoryChange) await send(.observeIsReceivingMessageChange) await send(.observeFileEditChange) } case .observeHistoryChange: return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$chatHistory.sink { _ in continuation.yield() } continuation.onTermination = { _ in cancellable.cancel() } } let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) { await send(.historyChanged) } for await _ in stream { await debouncedHistoryChange() } }.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true) case .observeIsReceivingMessageChange: return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$isReceivingMessage .sink { _ in continuation.yield() } continuation.onTermination = { _ in cancellable.cancel() } } for await _ in stream { await send(.isReceivingMessageChanged) } }.cancellable( id: CancelID.observeIsReceivingMessageChange(id), cancelInFlight: true ) case .observeFileEditChange: return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$fileEditMap .sink { _ in continuation.yield() } continuation.onTermination = { _ in cancellable.cancel() } } for await _ in stream { await send(.fileEditChanged) } }.cancellable( id: CancelID.observeFileEditChange(id), cancelInFlight: true ) case .historyChanged: state.history = service.chatHistory.flatMap { message in var all = [DisplayedChatMessage]() all.append(.init( id: message.id, role: { switch message.role { case .user: return .user case .assistant: return .assistant case .system: return .ignored } }(), text: message.content, imageReferences: message.contentImageReferences, references: message.references.map { .init( uri: $0.uri, status: $0.status, kind: $0.kind, referenceType: $0.referenceType ) }, followUp: message.followUp, suggestedTitle: message.suggestedTitle, errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, parentTurnId: message.parentTurnId, panelMessages: message.panelMessages, codeReviewRound: message.codeReviewRound, fileEdits: message.fileEdits, turnStatus: message.turnStatus, requestType: message.requestType, modelName: message.modelName, billingMultiplier: message.billingMultiplier )) return all } return .none case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage state.requestType = service.requestType return .none case .fileEditChanged: state.fileEditMap = service.fileEditMap let fileEditMap = state.fileEditMap let diffViewerController = state.diffViewerController return .run { _ in /// refresh diff view guard let diffViewerController, diffViewerController.diffViewerState == .shown else { return } if fileEditMap.isEmpty { await diffViewerController.hideWindow() return } guard let currentFileEdit = diffViewerController.currentFileEdit else { return } if let updatedFileEdit = fileEditMap[currentFileEdit.fileURL] { if updatedFileEdit != currentFileEdit { if updatedFileEdit.status == .undone, updatedFileEdit.toolName == .createFile { await diffViewerController.hideWindow() } else { await diffViewerController.showDiffWindow(fileEdit: updatedFileEdit) } } } else { await diffViewerController.hideWindow() } } case .binding: return .none case .chatMenu: return .none case let .upvote(id, rating): return .run { _ in await service.upvote(id, rating) } case let .downvote(id, rating): return .run { _ in await service.downvote(id, rating) } case let .copyCode(id): return .run { _ in await service.copyCode(id) } case let .insertCode(code): ChatInjector().insertCodeBlock(codeBlock: code) return .none // MARK: - Context case .resetCurrentEditor: state.currentEditor = nil return .none case let .setCurrentEditor(fileReference): state.currentEditor = fileReference return .none case let .addReference(ref): state.editor.addReference(ref) return .none case let .removeReference(ref): state.editor.removeReference(ref) return .none // MARK: - Image Context case let .addSelectedImage(imageReference): guard !state.attachedImages.contains(imageReference) else { return .none } state.editor.addImage(imageReference) return .run { send in await send(.resetContextProvider) } case let .removeSelectedImage(imageReference): guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none } state.editor.removeImage(imageReference) return .run { send in await send(.resetContextProvider) } // MARK: - Agent Edits case let .undoEdits(fileURLs): for fileURL in fileURLs { do { try service.undoFileEdit(for: fileURL) } catch { Logger.service.error("Failed to undo edit, \(error)") } } return .none case let .keepEdits(fileURLs): for fileURL in fileURLs { service.keepFileEdit(for: fileURL) } return .none case .resetEdits: service.resetFileEdits() return .none case let .discardFileEdits(fileURLs): for fileURL in fileURLs { try? service.discardFileEdit(for: fileURL) } return .none case let .openDiffViewWindow(fileURL): guard let fileEdit = state.fileEditMap[fileURL], let diffViewerController = state.diffViewerController else { return .none } return .run { _ in await diffViewerController.showDiffWindow(fileEdit: fileEdit) } case let .setDiffViewerController(chat): state.diffViewerController = .init(chat: chat) return .none case let .agentModeChanged(isAgentMode): state.isAgentMode = isAgentMode return .none case let .selectedAgentChanged(mode): state.selectedAgent = mode state.handOffClicked = false return .none // MARK: - Code Review case let .codeReview(.request(group)): return .run { send in await send(.discardCheckPoint) } case .codeReview: return .none // MARK: Chat Context case .reloadNextContext: guard let context = state.editor.popNextContext() else { return .none } state.chatContext = context return .run { send in await send(.focusOnTextField) } case .reloadPreviousContext: guard let projectURL = service.getProjectRootURL(), let context = state.editor.previousContext( from: state.history, projectURL: projectURL) else { return .none } let currentContext = state.chatContext state.chatContext = context state.editor.pushContext(currentContext) return .run { send in await send(.focusOnTextField) } case .resetContextProvider: state.editor.resetContextProvider() return .none // MARK: - External action case .observeFixErrorNotification: return .run { send in let publisher = NotificationCenter.default.publisher(for: .fixEditorErrorIssue) for await notification in publisher.values { guard service.chatTabInfo.isSelected, let issue = notification.userInfo?["editorErrorIssue"] as? EditorErrorIssue else { continue } await send(.fixEditorErrorIssue(issue)) } }.cancellable( id: CancelID.observeFixErrorNotification(id), cancelInFlight: true) case .fixEditorErrorIssue(let issue): guard issue.workspaceURL == service.getWorkspaceURL(), !issue.lineAnnotations.isEmpty else { return .none } guard !state.isReceivingMessage else { return .run { _ in await MainActor.run { NotificationCenter.default.post( name: .fixEditorErrorIssueError, object: nil, userInfo: ["error": FixEditorErrorIssueFailure.isReceivingMessage(id: issue.id)] ) } } } let errorAnnotationMessage: String = issue.lineAnnotations .map { "❗\($0.originalAnnotation)" } .joined(separator: "\n\n") let message = "Analyze and fix the following error(s): \n\n\(errorAnnotationMessage)" let skillSet = state.buildSkillSet(isCurrentEditorContextEnabled: enableCurrentEditorContext) let references: [ConversationAttachedReference] = [.file(.init(url: issue.fileURL))] let selectedModel = AppState.shared.getSelectedModel() let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.modelFamily let agentMode = AppState.shared.isAgentModeEnabled() // TODO: if we need to switch to agent mode or keep the current mode let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() return .run { _ in try await service.send( UUID().uuidString, content: message, skillSet: skillSet, references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) // MARK: - Check Point case let .restoreCheckPoint(messageId): guard let message = state.history.first(where: { $0.id == messageId }) else { return .none } if state.pendingCheckpointContext == nil { state.pendingCheckpointContext = state.chatContext } state.pendingCheckpointMessageId = messageId // Reload the chat context let messagesAfterCheckpoint = state.messagesAfterCheckpoint if !messagesAfterCheckpoint.isEmpty, let userMessage = messagesAfterCheckpoint.first, userMessage.role == .user, let projectURL = service.getProjectRootURL() { state.chatContext = .from(userMessage, projectURL: projectURL) } let isReceivingMessage = state.isReceivingMessage return .run { send in await send(.restoreFileEdits) await send(.reloadWorkingset(message)) if isReceivingMessage { await send(.stopRespondingButtonTapped) } } case .restoreFileEdits: // Revert file edits in messages after checkpoint let messagesAfterCheckpoint = state.messagesAfterCheckpoint guard !messagesAfterCheckpoint.isEmpty else { return .none } return .run { _ in var restoredURLs = Set() let fileManager = FileManager.default // Revert the file edit. From the oldest to newest for message in messagesAfterCheckpoint { let fileEdits = message.fileEdits guard !fileEdits.isEmpty else { continue } for fileEdit in fileEdits { guard !restoredURLs.contains(fileEdit.fileURL) else { continue } restoredURLs.insert(fileEdit.fileURL) do { switch fileEdit.toolName { case .createFile: try fileManager.removeItem(at: fileEdit.fileURL) case .insertEditIntoFile: try fileEdit.originalContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) default: break } } catch { Logger.client.error(">>> Failed to restore file Edit: \(error)") } } } } case .undoCheckPoint: if let context = state.pendingCheckpointContext { state.chatContext = context state.pendingCheckpointContext = nil } let reversedMessagesAfterCheckpoint = Array(state.messagesAfterCheckpoint.reversed()) state.pendingCheckpointMessageId = nil // Redo file edits in messages after checkpoint guard !reversedMessagesAfterCheckpoint.isEmpty else { return .none } return .run { send in var redoedURL = Set() let lastMessage = reversedMessagesAfterCheckpoint.first for message in reversedMessagesAfterCheckpoint { let fileEdits = message.fileEdits guard !fileEdits.isEmpty else { continue } for fileEdit in fileEdits { guard !redoedURL.contains(fileEdit.fileURL) else { continue } redoedURL.insert(fileEdit.fileURL) do { switch fileEdit.toolName { case .createFile, .insertEditIntoFile: try fileEdit.modifiedContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8) default: break } } catch { Logger.client.error(">>> failed to undo fileEdit: \(error)") } } } // Recover fileEdits working set if let lastMessage { await send(.reloadWorkingset(lastMessage)) } } case .discardCheckPoint: let messagesAfterCheckpoint = state.messagesAfterCheckpoint state.pendingCheckpointMessageId = nil state.pendingCheckpointContext = nil return .run { _ in if !messagesAfterCheckpoint.isEmpty { await service.deleteMessages(ids: messagesAfterCheckpoint.map { $0.id }) } } case let .reloadWorkingset(message): return .run { _ in service.resetFileEdits() for fileEdit in message.fileEdits { service.updateFileEdits(by: fileEdit) } } case .openAutoApproveSettings: return .run { _ in try launchHostAppToolsSettingsAutoApprove() } } } } } @Reducer struct ChatMenu { @ObservableState struct State: Equatable { var systemPrompt: String = "" var extraSystemPrompt: String = "" var temperatureOverride: Double? = nil var chatModelIdOverride: String? = nil } enum Action: Equatable { case appear case refresh case customCommandButtonTapped(CustomCommand) } let service: ChatService var body: some ReducerOf { Reduce { state, action in switch action { case .appear: return .run { await $0(.refresh) } case .refresh: return .none case let .customCommandButtonTapped(command): return .run { _ in try await service.handleCustomCommand(command) } } } } } private actor TimedDebounceFunction { let duration: TimeInterval let block: () async -> Void var task: Task? var lastFireTime: Date = .init(timeIntervalSince1970: 0) init(duration: TimeInterval, block: @escaping () async -> Void) { self.duration = duration self.block = block } func callAsFunction() async { task?.cancel() if lastFireTime.timeIntervalSinceNow < -duration { await fire() task = nil } else { task = Task.detached { [weak self, duration] in try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) await self?.fire() } } } func fire() async { lastFireTime = Date() await block() } } public struct EditorErrorIssue: Equatable { public let lineAnnotations: [EditorInformation.LineAnnotation] public let fileURL: URL public let workspaceURL: URL public let id: String public init( lineAnnotations: [EditorInformation.LineAnnotation], fileURL: URL, workspaceURL: URL, id: String ) { self.lineAnnotations = lineAnnotations self.fileURL = fileURL self.workspaceURL = workspaceURL self.id = id } } public enum FixEditorErrorIssueFailure: Equatable { case isReceivingMessage(id: String) } public extension Notification.Name { static let fixEditorErrorIssue = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssue") static let fixEditorErrorIssueError = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssueError") }