diff --git a/.github/workflows/auto-create-release-pr.yml b/.github/workflows/auto-create-release-pr.yml index 78dfb844..68556edc 100644 --- a/.github/workflows/auto-create-release-pr.yml +++ b/.github/workflows/auto-create-release-pr.yml @@ -47,4 +47,5 @@ jobs: run: | gh pr merge "${{ github.ref_name }}" \ --auto \ + --merge \ --delete-branch diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index 4e289e57..6dbb0e0f 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -175,7 +175,7 @@ actor ExtensionServiceLauncher { return configuration }() ) { app, error in - if let error = error { + if error != nil { continuation.resume(returning: nil) } else { continuation.resume(returning: app) diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index f01bb0ad..d45adb9f 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -144,7 +144,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Start cleanup in background without waiting Task { - let quitTask = Task { + _ = Task { let service = try? getService() try? await service?.quitService() } diff --git a/Core/Package.swift b/Core/Package.swift index 08ce8d4a..70c40b3c 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -75,6 +75,7 @@ let package = Package( dependencies: [ "SuggestionWidget", "SuggestionService", + "SuggestionInjector", "ChatService", "PromptToCodeService", "ConversationTab", @@ -123,6 +124,7 @@ let package = Package( "Client", "LaunchAgentManager", "GitHubCopilotViewModel", + "UpdateChecker", .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -202,6 +204,7 @@ let package = Package( name: "ConversationTab", dependencies: [ "ChatService", + "GitHubCopilotViewModel", .product(name: "SharedUIComponents", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Logger", package: "Tool"), @@ -225,6 +228,7 @@ let package = Package( "ConversationTab", "GitHubCopilotViewModel", "PersistMiddleware", + .product(name: "CGEventOverride", package: "CGEventOverride"), .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index f69afe52..d9dcf0cd 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -84,6 +84,15 @@ public final class ChatService: ChatServiceType, ObservableObject { private var pendingToolCallRequests: [String: ToolCallRequest] = [:] // Workaround: toolConfirmation request does not have parent turnId private var conversationTurnTracking = ConversationTurnTrackingState() + + /// Single source of truth for an in-flight streaming thinking block. Sealed when the turn ends + /// or a non-thinking payload arrives. `clientEntryId` is stable across server delta `id` churn. + private struct ActiveThinkingCursor { + let clientEntryId: UUID + let targetMessageId: String + let originTurnId: String + } + private var activeThinking: ActiveThinkingCursor? = nil init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), @@ -241,14 +250,24 @@ public final class ChatService: ChatServiceType, ObservableObject { // this will be triggerred in conversation tab if needed public func restoreIfNeeded() { guard self.isRestored == false else { return } - + Task { - let storedChatMessages = fetchAllChatMessagesFromStorage() + var storedChatMessages = fetchAllChatMessagesFromStorage() + // Force-seal any thinking entries that were persisted mid-stream (e.g. app crashed + // before the seal sweep ran). Otherwise they'd render with the placeholder "Thinking" + // title forever. + for messageIndex in storedChatMessages.indices where storedChatMessages[messageIndex].role == .assistant { + for path in Self.allThinkingPaths(in: storedChatMessages[messageIndex]) { + Self.mutateThinking(at: path, in: &storedChatMessages[messageIndex]) { entry in + if !entry.isComplete { entry.isComplete = true } + } + } + } await mutateHistory { history in history.append(contentsOf: storedChatMessages) } } - + self.isRestored = true } @@ -778,28 +797,68 @@ public final class ChatService: ChatServiceType, ObservableObject { if let reply = progress.reply { content = reply } - + if let progressReferences = progress.references, !progressReferences.isEmpty { references = progressReferences.toConversationReferences() } - + if let progressSteps = progress.steps, !progressSteps.isEmpty { steps = progressSteps } - + if let progressAgentRounds = progress.editAgentRounds, !progressAgentRounds.isEmpty { editAgentRounds = progressAgentRounds } - - if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil { + + let progressThinkingDelta = progress.thinking + let hasThinking = !(progressThinkingDelta?.text?.allSatisfy { $0.isEmpty } ?? true) + let hasNonThinking = !content.isEmpty || !references.isEmpty || !steps.isEmpty || !editAgentRounds.isEmpty + + // Resolve the in-flight cursor against this event. The cursor is sealed when the active + // turn changes, or when a non-thinking payload arrives signalling that reasoning has + // ended and the model is now speaking/acting. + if let cursor = activeThinking, cursor.originTurnId != id { + sealActiveThinking() + } + if !hasThinking, hasNonThinking, activeThinking != nil { + sealActiveThinking() + } + + if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil && !hasThinking { return } - + let messageContent = content let messageReferences = references let messageSteps = steps - let messageAgentRounds = editAgentRounds + var messageAgentRounds = editAgentRounds let messageParentTurnId = parentTurnId + var messageThinking: [MessageThinking] = [] + + if hasThinking, let progressThinkingDelta { + // Open a cursor on the first delta of a streaming block. Subsequent deltas reuse the + // same `clientEntryId` so `mergeThinking` concatenates into one entry even when the + // server's `id` changes mid-stream. + let cursor = activeThinking ?? { + let opened = ActiveThinkingCursor( + clientEntryId: UUID(), + targetMessageId: parentTurnId ?? id, + originTurnId: id + ) + activeThinking = opened + return opened + }() + let entry = MessageThinking(from: progressThinkingDelta, clientEntryId: cursor.clientEntryId) + // Route the entry: into the last agent round when this event carries one (mid-tool-loop + // reasoning, including sub-agent rounds), otherwise onto the message itself (pre-tool + // reasoning). For sub-agent events, ChatMemory.appendMessage's parent-turn merge will + // forward the round's thinking into the parent's last sub-round via `mergeThinking`. + if let lastIndex = messageAgentRounds.indices.last { + messageAgentRounds[lastIndex].thinking.append(entry) + } else { + messageThinking = [entry] + } + } Task { let message = ChatMessage( @@ -809,6 +868,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: messageReferences, steps: messageSteps, editAgentRounds: messageAgentRounds, + thinking: messageThinking, parentTurnId: messageParentTurnId, turnStatus: .inProgress ) @@ -817,8 +877,132 @@ public final class ChatService: ChatServiceType, ObservableObject { } } + /// Seals the cursor's entry: marks it `isComplete`, persists the owning message, and kicks off + /// the LSP title-generation request. Looking up by `clientEntryId` (set when the cursor was + /// opened) makes this independent of the server's per-delta `id` and of which location the + /// entry was routed to (top-level message, agent round, or sub-agent round). + private func sealActiveThinking() { + guard let cursor = activeThinking else { return } + activeThinking = nil + Task { + var sealedText: String? = nil + var sealedMessage: ChatMessage? = nil + await memory.mutateHistory { history in + guard let messageIndex = history.firstIndex(where: { $0.id == cursor.targetMessageId }), + history[messageIndex].role == .assistant, + let path = Self.findThinkingPath(clientEntryId: cursor.clientEntryId, in: history[messageIndex]) + else { return } + Self.mutateThinking(at: path, in: &history[messageIndex]) { entry in + guard !entry.isComplete else { return } + entry.isComplete = true + if let text = entry.text?.joined(), !text.isEmpty { + sealedText = text + } + } + sealedMessage = history[messageIndex] + } + if let sealedMessage { + saveChatMessageToStorage(sealedMessage) + } + guard let sealedText else { return } + await requestThinkingTitle(for: sealedText, cursor: cursor) + } + } + + private func requestThinkingTitle(for thinkingText: String, cursor: ActiveThinkingCursor) async { + let extractedTitles = MessageThinking.parseSections(from: thinkingText).compactMap { $0.title } + let params = GenerateThinkingTitleParams( + thinkingContent: extractedTitles.isEmpty ? thinkingText : nil, + extractedTitles: extractedTitles.isEmpty ? nil : extractedTitles + ) + do { + guard let response = try await conversationProvider?.generateThinkingTitle(params), + !response.title.isEmpty else { return } + let trimmed = response.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = trimmed.count > 80 ? String(trimmed.prefix(80)) + "\u{2026}" : trimmed + guard !title.isEmpty else { return } + var titledMessage: ChatMessage? = nil + await memory.mutateHistory { history in + guard let messageIndex = history.firstIndex(where: { $0.id == cursor.targetMessageId }), + history[messageIndex].role == .assistant, + let path = Self.findThinkingPath(clientEntryId: cursor.clientEntryId, in: history[messageIndex]) + else { return } + Self.mutateThinking(at: path, in: &history[messageIndex]) { $0.title = title } + titledMessage = history[messageIndex] + } + if let titledMessage { + saveChatMessageToStorage(titledMessage) + } + } catch { + Logger.gitHubCopilot.debug("Failed to generate thinking title: \(error)") + } + } + + /// Path to a `MessageThinking` entry inside an assistant `ChatMessage`. Covers the three + /// places thinking can live: top-level on the message, on an agent round, or on a sub-agent + /// round under an agent round. + private enum ThinkingPath { + case message(entryIndex: Int) + case round(roundIndex: Int, entryIndex: Int) + case subRound(roundIndex: Int, subRoundIndex: Int, entryIndex: Int) + } + + private static func findThinkingPath(clientEntryId: UUID, in message: ChatMessage) -> ThinkingPath? { + let predicate: (MessageThinking) -> Bool = { $0.clientEntryId == clientEntryId } + if let entryIndex = message.thinking.firstIndex(where: predicate) { + return .message(entryIndex: entryIndex) + } + for (roundIndex, round) in message.editAgentRounds.enumerated() { + if let entryIndex = round.thinking.firstIndex(where: predicate) { + return .round(roundIndex: roundIndex, entryIndex: entryIndex) + } + for (subRoundIndex, subRound) in (round.subAgentRounds ?? []).enumerated() { + if let entryIndex = subRound.thinking.firstIndex(where: predicate) { + return .subRound(roundIndex: roundIndex, subRoundIndex: subRoundIndex, entryIndex: entryIndex) + } + } + } + return nil + } + + /// All `ThinkingPath`s in the message, in stable visit order. Used by sweeps that need to + /// touch every entry without knowing the cursor's `clientEntryId`. + private static func allThinkingPaths(in message: ChatMessage) -> [ThinkingPath] { + var paths: [ThinkingPath] = [] + for entryIndex in message.thinking.indices { + paths.append(.message(entryIndex: entryIndex)) + } + for (roundIndex, round) in message.editAgentRounds.enumerated() { + for entryIndex in round.thinking.indices { + paths.append(.round(roundIndex: roundIndex, entryIndex: entryIndex)) + } + for (subRoundIndex, subRound) in (round.subAgentRounds ?? []).enumerated() { + for entryIndex in subRound.thinking.indices { + paths.append(.subRound(roundIndex: roundIndex, subRoundIndex: subRoundIndex, entryIndex: entryIndex)) + } + } + } + return paths + } + + private static func mutateThinking(at path: ThinkingPath, in message: inout ChatMessage, _ mutate: (inout MessageThinking) -> Void) { + switch path { + case .message(let entryIndex): + mutate(&message.thinking[entryIndex]) + case .round(let roundIndex, let entryIndex): + mutate(&message.editAgentRounds[roundIndex].thinking[entryIndex]) + case .subRound(let roundIndex, let subRoundIndex, let entryIndex): + guard var subRounds = message.editAgentRounds[roundIndex].subAgentRounds else { return } + mutate(&subRounds[subRoundIndex].thinking[entryIndex]) + message.editAgentRounds[roundIndex].subAgentRounds = subRounds + } + } + private func handleProgressEnd(token: String, progress: ConversationProgressEnd) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } + + sealActiveThinking() + let followUp = progress.followUp if let CLSError = progress.error { @@ -902,7 +1086,7 @@ public final class ChatService: ChatServiceType, ObservableObject { Task { let message = ChatMessage( - assistantMessageWithId: progress.turnId, + assistantMessageWithId: progress.turnId, chatTabID: chatTabInfo.id, followUp: followUp, suggestedTitle: progress.suggestedTitle, @@ -919,7 +1103,11 @@ public final class ChatService: ChatServiceType, ObservableObject { isReceivingMessage = false isSummarizingConversation = false requestType = nil - + // The cursor is normally cleared by sealActiveThinking() in handleProgressEnd; clear it + // here as a safety net for cancellation/error paths that bypass the end handler. The + // belt-and-suspenders sweep below catches any orphan unsealed entries. + activeThinking = nil + // Clear turn tracking data conversationTurnTracking.reset() @@ -952,6 +1140,15 @@ public final class ChatService: ChatServiceType, ObservableObject { history[lastIndex].steps[i].status = .cancelled } } + + // Belt-and-suspenders: mark any orphan unsealed thinking complete on turn end. The + // cursor seal in handleProgressEnd handles the normal path; this catches cancel/ + // error cases that bypass it. + for path in Self.allThinkingPaths(in: history[lastIndex]) { + Self.mutateThinking(at: path, in: &history[lastIndex]) { entry in + if !entry.isComplete { entry.isComplete = true } + } + } for i in 0.. Bool { diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift index d3a47556..8be738f6 100644 --- a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift @@ -2,7 +2,7 @@ import Foundation public typealias ConversationID = String -public enum AutoApprovalScope: Hashable { +public enum AutoApprovalScope: Hashable, Sendable { case session(ConversationID) /// Applies to all workspaces. Persisted in `UserDefaults.autoApproval`. case global diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 3c570890..2c727bfc 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -30,6 +30,7 @@ public struct DisplayedChatMessage: Equatable { public var suggestedTitle: String? = nil public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] + public var thinking: [MessageThinking] = [] public var editAgentRounds: [AgentRound] = [] public var parentTurnId: String? = nil public var panelMessages: [CopilotShowMessageParams] = [] @@ -50,6 +51,7 @@ public struct DisplayedChatMessage: Equatable { suggestedTitle: String? = nil, errorMessages: [String] = [], steps: [ConversationProgressStep] = [], + thinking: [MessageThinking] = [], editAgentRounds: [AgentRound] = [], parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], @@ -69,6 +71,7 @@ public struct DisplayedChatMessage: Equatable { self.suggestedTitle = suggestedTitle self.errorMessages = errorMessages self.steps = steps + self.thinking = thinking self.editAgentRounds = editAgentRounds self.parentTurnId = parentTurnId self.panelMessages = panelMessages @@ -1067,6 +1070,7 @@ struct Chat { suggestedTitle: message.suggestedTitle, errorMessages: message.errorMessages, steps: message.steps, + thinking: message.thinking, editAgentRounds: message.editAgentRounds, parentTurnId: message.parentTurnId, panelMessages: message.panelMessages, @@ -1230,7 +1234,7 @@ struct Chat { return .none // MARK: - Code Review - case let .codeReview(.request(group)): + case .codeReview(.request(_)): return .run { send in await send(.discardCheckPoint) } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index ed1498f1..f866156f 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -23,6 +23,7 @@ private let r: Double = 4 public struct ChatPanel: View { @Perception.Bindable var chat: StoreOf @Namespace var inputAreaNamespace + @ObservedObject private var rateLimitNotifier = RateLimitNotifierImpl.shared public var body: some View { WithPerceptionTracking { @@ -55,12 +56,20 @@ public struct ChatPanel: View { } } + if let warning = rateLimitNotifier.currentWarning { + RateLimitWarningBanner(message: warning.message) { + rateLimitNotifier.dismissWarning() + } + .scaledPadding(.horizontal, 24) + .scaledPadding(.vertical, 8) + } + if chat.fileEditMap.count > 0 { WorkingSetView(chat: chat) .dimWithExitEditMode(chat) .scaledPadding(.horizontal, 24) } - + ChatPanelInputArea(chat: chat, r: r, editorMode: .input) .dimWithExitEditMode(chat) .scaledPadding(.horizontal, 16) @@ -135,6 +144,36 @@ private struct ListHeightPreferenceKey: PreferenceKey { } } +private struct ScrollViewConfigurator: NSViewRepresentable { + let configure: (NSScrollView) -> Void + + final class Coordinator { + var didConfigure = false + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + applyOnce(view: view, coordinator: context.coordinator) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + applyOnce(view: nsView, coordinator: context.coordinator) + } + + private func applyOnce(view: NSView, coordinator: Coordinator) { + guard !coordinator.didConfigure else { return } + DispatchQueue.main.async { + guard !coordinator.didConfigure, + let scrollView = view.enclosingScrollView else { return } + coordinator.didConfigure = true + configure(scrollView) + } + } +} + struct ChatPanelMessages: View { let chat: StoreOf @State var cancellable = Set() @@ -154,17 +193,34 @@ struct ChatPanelMessages: View { WithPerceptionTracking { ScrollViewReader { proxy in GeometryReader { listGeo in - List { - Group { + ScrollView(.vertical, showsIndicators: true) { + // VStack with a flexible trailing Spacer absorbs empty space when + // content is shorter than the viewport, so content stays naturally + // top-aligned. When content grows past the viewport, the Spacer + // collapses to its minLength and the VStack overflows the + // ScrollView's content area as expected. This avoids the List's + // remembered-bottom-anchor behavior that pushed earlier content up + // whenever a child view's height changed. + VStack(alignment: .leading, spacing: 0) { + ScrollViewConfigurator { scrollView in + scrollView.scrollerStyle = .overlay + scrollView.verticalScroller?.scrollerStyle = .overlay + scrollView.autohidesScrollers = true + } + .frame(width: 0, height: 0) + + Color.clear + .frame(height: 1) + .id(topID) ChatHistory(chat: chat) .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) - Spacer(minLength: 12) + Color.clear + .frame(height: 12) .id(bottomID) - .listRowInsets(EdgeInsets()) .onAppear { isBottomHidden = false if !didScrollToBottomOnAppearOnce { @@ -182,14 +238,16 @@ struct ChatPanelMessages: View { value: offset ) }) + + Spacer(minLength: 0) } - .listRowSeparator(.hidden) - } - .listStyle(.plain) - .scaledPadding(.leading, 8) - .listRowBackground(EmptyView()) - .modify { view in - view.scrollContentBackground(.hidden) + .frame( + minWidth: 0, + maxWidth: .infinity, + minHeight: listGeo.size.height, + alignment: .topLeading + ) + .scaledPadding(.horizontal, 16) } .coordinateSpace(name: scrollSpace) .preference( diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index fcc5ad9a..8d75db57 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -21,6 +21,7 @@ struct BotMessage: View { var followUp: ConversationFollowUp? { message.followUp } var errorMessages: [String] { message.errorMessages } var steps: [ConversationProgressStep] { message.steps } + var thinking: [MessageThinking] { message.thinking } var editAgentRounds: [AgentRound] { message.editAgentRounds } var panelMessages: [CopilotShowMessageParams] { message.panelMessages } var codeReviewRound: CodeReviewRound? { message.codeReviewRound } @@ -90,9 +91,16 @@ struct BotMessage: View { // progress step if steps.count > 0 { ProgressStep(steps: steps) - + } - + + ForEach(Array(thinking.enumerated()), id: \.offset) { index, entry in + ThinkingView( + thinking: entry, + isStreaming: index == thinking.count - 1 && isThinkingStreaming() + ) + } + if !panelMessages.isEmpty { WithPerceptionTracking { ForEach(panelMessages.indices, id: \.self) { index in @@ -100,11 +108,11 @@ struct BotMessage: View { } } } - + if editAgentRounds.count > 0 { - ProgressAgentRound(rounds: editAgentRounds, chat: chat) + ProgressAgentRound(rounds: editAgentRounds, chat: chat, isStreaming: isThinkingStreaming()) } - + if !text.isEmpty { Group{ ThemedMarkdownText(text: text, chat: chat) @@ -241,6 +249,14 @@ struct BotMessage: View { let lastMessage = chat.history.last return lastMessage?.role == .assistant && lastMessage?.id == id } + + private func isThinkingStreaming() -> Bool { + guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false } + switch message.turnStatus { + case .success, .error, .cancelled: return false + default: return true + } + } } private struct TurnStatusView: View { diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift index fc970828..f002f643 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -10,13 +10,25 @@ import SwiftUI struct ProgressAgentRound: View { let rounds: [AgentRound] let chat: StoreOf + var isStreaming: Bool = false var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { - ForEach(rounds, id: \.roundId) { round in + ForEach(Array(rounds.enumerated()), id: \.element.roundId) { roundIndex, round in + let isLastRound = roundIndex == rounds.count - 1 VStack(alignment: .leading, spacing: 8) { - ThemedMarkdownText(text: round.reply, chat: chat) + ForEach(Array(round.thinking.enumerated()), id: \.offset) { entryIndex, entry in + ThinkingView( + thinking: entry, + isStreaming: isStreaming + && isLastRound + && entryIndex == round.thinking.count - 1 + ) + } + if !round.reply.isEmpty { + ThemedMarkdownText(text: round.reply, chat: chat) + } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } @@ -42,7 +54,12 @@ struct SubAgentRounds: View { VStack(alignment: .leading, spacing: 8) { ForEach(rounds, id: \.roundId) { round in VStack(alignment: .leading, spacing: 8) { - ThemedMarkdownText(text: round.reply, chat: chat) + ForEach(Array(round.thinking.enumerated()), id: \.offset) { _, entry in + ThinkingView(thinking: entry, isStreaming: false) + } + if !round.reply.isEmpty { + ThemedMarkdownText(text: round.reply, chat: chat) + } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } @@ -384,23 +401,56 @@ struct GenericToolTitleView: View { struct ProgressAgentRound_Preview: PreviewProvider { static let agentRounds: [AgentRound] = [ .init(roundId: 1, reply: "this is agent step", toolCalls: [ + // Completed read file .init( id: "toolcall_001", - name: "Tool Call 1", - progressMessage: "Read Tool Call 1", - status: .completed, - error: nil), + name: ServerToolName.readFile.rawValue, + progressMessage: "Read src/AppDelegate.swift", + status: .completed), + // Completed file search with results .init( id: "toolcall_002", - name: "Tool Call 2", - progressMessage: "Running Tool Call 2", + name: ServerToolName.findFiles.rawValue, + progressMessage: "Searched for files matching query: **/*.swift", + status: .completed, + resultDetails: [ + .fileLocation(.init(uri: "file:///src/App.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 10, character: 0)))), + .fileLocation(.init(uri: "file:///src/Model.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 5, character: 0)))), + .fileLocation(.init(uri: "file:///src/ViewModel.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 8, character: 0)))), + ]), + // Completed create file (expandable) + .init( + id: "toolcall_003", + name: ToolName.createFile.rawValue, + progressMessage: "Created src/NewFeature.swift", + status: .completed, + result: [.text("```swift\nstruct NewFeature {\n var name: String\n}\n```")]), + // Completed replace string (expandable) + .init( + id: "toolcall_004", + name: ServerToolName.replaceString.rawValue, + progressMessage: "Edited src/Config.swift", + status: .completed, + result: [.text("```diff\n- let version = \"1.0\"\n+ let version = \"2.0\"\n```")]), + // Running tool + .init( + id: "toolcall_005", + name: ServerToolName.codebase.rawValue, + progressMessage: "Searching codebase for references", status: .running), + // Error tool + .init( + id: "toolcall_006", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read missing_file.swift", + status: .error, + error: "File not found"), ]), ] static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) - .frame(width: 300, height: 300) + .frame(width: 400, height: 500) } } diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift index 9238a932..fc87e3bb 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift @@ -510,22 +510,22 @@ private struct ToolStatusDetailsView: View { @AppStorage(\.fontScale) var fontScale var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { Button(action: { isExpanded.toggle() }) { - HStack(spacing: 8) { + HStack(spacing: 2) { title - Spacer() - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .resizable() .scaledToFit() .padding(4) .scaledFrame(width: 16, height: 16) .scaledFont(size: 10, weight: .medium) + + Spacer() } .contentShape(RoundedRectangle(cornerRadius: 6)) } @@ -534,9 +534,6 @@ private struct ToolStatusDetailsView: View { .toolStatusStyle(withBackground: !isExpanded, fontScale: fontScale) if isExpanded { - Divider() - .background(Color.agentToolStatusDividerColor) - content .scaledPadding(.horizontal, 8) } @@ -552,10 +549,6 @@ private extension View { if withBackground { view .scaledPadding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) - ) } else { view } @@ -578,3 +571,64 @@ private extension View { } } } + +// MARK: - Preview + +struct ToolStatusItemView_Preview: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 4) { + // Completed read file + ToolStatusItemView(tool: .init( + id: "1", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read src/AppDelegate.swift", + status: .completed + )) + // Completed file search + ToolStatusItemView(tool: .init( + id: "2", + name: ServerToolName.findFiles.rawValue, + progressMessage: "Searched for files matching query: **/*.swift", + status: .completed, + resultDetails: [ + .fileLocation(.init(uri: "file:///src/App.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 10, character: 0)))), + .fileLocation(.init(uri: "file:///src/Model.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 5, character: 0)))), + ] + )) + // Completed create file (expandable) + ToolStatusItemView(tool: .init( + id: "3", + name: ToolName.createFile.rawValue, + progressMessage: "Created src/NewFeature.swift", + status: .completed, + result: [.text("struct NewFeature {\n var name: String\n}")] + )) + // Completed replace string (expandable) + ToolStatusItemView(tool: .init( + id: "4", + name: ServerToolName.replaceString.rawValue, + progressMessage: "Edited src/Config.swift", + status: .completed, + result: [.text("- let version = \"1.0\"\n+ let version = \"2.0\"")] + )) + // Running + ToolStatusItemView(tool: .init( + id: "5", + name: ServerToolName.codebase.rawValue, + progressMessage: "Searching codebase", + status: .running + )) + // Error + ToolStatusItemView(tool: .init( + id: "6", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read missing_file.swift", + status: .error, + error: "File not found" + )) + } + .padding() + .frame(width: 400) + .colorScheme(.dark) + } +} diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift index f5047793..89062b0a 100644 --- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -2,16 +2,19 @@ import SwiftUI import SharedUIComponents public enum BannerStyle { + case info case warning var iconName: String { switch self { - case .warning: return "exclamationmark.triangle" + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" } } var color: Color { switch self { + case .info: return .blue case .warning: return .orange } } @@ -19,6 +22,8 @@ public enum BannerStyle { struct NotificationBanner: View { var style: BannerStyle + var isDismissable: Bool = false + var onDismiss: (() -> Void)? = nil @ViewBuilder var content: () -> Content @AppStorage(\.chatFontSize) var chatFontSize @@ -31,15 +36,25 @@ struct NotificationBanner: View { VStack(alignment: .leading, spacing: 8) { content() } + .frame(maxWidth: .infinity, alignment: .leading) + + if isDismissable { + Button(action: { onDismiss?() }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + } } .scaledFont(size: chatFontSize - 1) } .frame(maxWidth: .infinity, alignment: .topLeading) .scaledPadding(.vertical, 10) .scaledPadding(.horizontal, 12) + .background(Color("BannerBackgroundColor")) .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .stroke(Color("BannerBorderColor"), lineWidth: 1) ) } } diff --git a/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift b/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift new file mode 100644 index 00000000..7fd01e4c --- /dev/null +++ b/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift @@ -0,0 +1,43 @@ +import SharedUIComponents +import SwiftUI + +struct RateLimitWarningBanner: View { + let message: String + let onDismiss: () -> Void + + @State private var isLinkHovered = false + + var body: some View { + NotificationBanner(style: .info, isDismissable: true, onDismiss: onDismiss) { + VStack(alignment: .leading, spacing: 8) { + Text(message) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { + if let url = URL(string: "https://aka.ms/github-copilot-rate-limit-error") { + NSWorkspace.shared.open(url) + } + }) { + Text("Learn more") + .underline(isLinkHovered) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .onHover { isHovered in + isLinkHovered = isHovered + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .onDisappear { + NSCursor.pop() + } + } + } + } +} diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index 086d724e..f9a1409b 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -174,15 +174,14 @@ struct MarkdownCodeBlockView: View { struct ThemedMarkdownText_Previews: PreviewProvider { static var previews: some View { - let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") ThemedMarkdownText( - text:""" - ```swift - let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in - return a + b - } - ``` - """, - context: .init(onInsert: {_ in print("Inserted") })) + text: """ + ```swift + let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in + return a + b + } + ``` + """, + context: .init(onInsert: { _ in print("Inserted") })) } } diff --git a/Core/Sources/ConversationTab/Views/ThinkingView.swift b/Core/Sources/ConversationTab/Views/ThinkingView.swift new file mode 100644 index 00000000..777bf7e2 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ThinkingView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import ComposableArchitecture +import ConversationServiceProvider +import SharedUIComponents + +struct ThinkingView: View { + let thinking: MessageThinking + let isStreaming: Bool + + @AppStorage(\.chatFontSize) var chatFontSize + @State private var isExpandedOverride: Bool? = nil + + private var sections: [ThinkingSection] { + MessageThinking.parseSections(from: thinking.text?.joined() ?? "") + } + + private var titleText: String { + if isStreaming { + return "Thinking..." + } + if let title = thinking.title, !title.isEmpty { + return title + } + return "Thinking" + } + + private var isExpanded: Bool { + if let override = isExpandedOverride { return override } + return isStreaming + } + + private var isAutoExpandedWhileStreaming: Bool { + isStreaming && isExpandedOverride == nil + } + + private static let autoExpandMaxHeight: CGFloat = 180 + private static let scrollAnchorID = "thinking-bottom-anchor" + + var body: some View { + WithPerceptionTracking { + let sections = sections + let hasContent = sections.contains { $0.title != nil || !$0.body.isEmpty } + if hasContent || isStreaming { + content(sections: sections, hasContent: hasContent) + } + } + } + + @ViewBuilder + private func content(sections: [ThinkingSection], hasContent: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + isExpandedOverride = !isExpanded + } label: { + HStack(spacing: 2) { + Text(titleText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledFont(size: 10, weight: .medium) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if isExpanded, hasContent { + sectionsContainer(sections: sections) + } + } + } + + @ViewBuilder + private func sectionsContainer(sections: [ThinkingSection]) -> some View { + let stack = VStack(alignment: .leading, spacing: 8) { + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + sectionView(section) + } + Color.clear + .frame(height: 0) + .id(Self.scrollAnchorID) + } + .fixedSize(horizontal: false, vertical: true) + + if isAutoExpandedWhileStreaming { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + stack + } + .frame(maxHeight: Self.autoExpandMaxHeight) + .onChange(of: thinking.text?.joined() ?? "") { _ in + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(Self.scrollAnchorID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(Self.scrollAnchorID, anchor: .bottom) + } + } + } else { + stack + } + } + + @ViewBuilder + private func sectionView(_ section: ThinkingSection) -> some View { + HStack(alignment: .top, spacing: 8) { + VStack(spacing: 4) { + Circle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 4, height: 4) + .padding(.top, 6) + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 1) + .frame(maxHeight: .infinity) + } + .frame(width: 4) + + VStack(alignment: .leading, spacing: 4) { + if let title = section.title, !title.isEmpty { + Text(title) + .scaledFont(size: chatFontSize - 1) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if !section.body.isEmpty { + ThemedMarkdownText( + text: section.body, + context: MarkdownActionProvider(supportInsert: false), + foregroundColor: .secondary + ) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index 07568796..4ebfe1a2 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -262,7 +262,7 @@ extension TabToAcceptSuggestion { return (false, "No filespace", nil) } - var codeSuggestionType: CodeSuggestionType? = { + let codeSuggestionType: CodeSuggestionType? = { if let _ = filespace.presentingSuggestion { return .codeCompletion } if let _ = filespace.presentingNESSuggestion { return .nes } return nil diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index d1837411..33590569 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -15,6 +15,7 @@ extension ChatMessage { var suggestedTitle: String? var errorMessages: [String] = [] var steps: [ConversationProgressStep] + var thinking: [MessageThinking] var editAgentRounds: [AgentRound] var parentTurnId: String? var panelMessages: [CopilotShowMessageParams] @@ -35,6 +36,14 @@ extension ChatMessage { suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle) errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] + // Decode thinking as either an array (current format) or a single value (legacy format). + if let array = try? container.decodeIfPresent([MessageThinking].self, forKey: .thinking) { + thinking = array + } else if let single = try? container.decodeIfPresent(MessageThinking.self, forKey: .thinking) { + thinking = [single] + } else { + thinking = [] + } editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] parentTurnId = try container.decodeIfPresent(String.self, forKey: .parentTurnId) panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] @@ -55,6 +64,7 @@ extension ChatMessage { suggestedTitle: String?, errorMessages: [String] = [], steps: [ConversationProgressStep]?, + thinking: [MessageThinking] = [], editAgentRounds: [AgentRound]? = nil, parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams]? = nil, @@ -72,6 +82,7 @@ extension ChatMessage { self.suggestedTitle = suggestedTitle self.errorMessages = errorMessages self.steps = steps ?? [] + self.thinking = thinking self.editAgentRounds = editAgentRounds ?? [] self.parentTurnId = parentTurnId self.panelMessages = panelMessages ?? [] @@ -93,6 +104,7 @@ extension ChatMessage { suggestedTitle: self.suggestedTitle, errorMessages: self.errorMessages, steps: self.steps, + thinking: self.thinking, editAgentRounds: self.editAgentRounds, parentTurnId: self.parentTurnId, panelMessages: self.panelMessages, @@ -133,6 +145,7 @@ extension ChatMessage { rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + thinking: turnItemData.thinking, parentTurnId: turnItemData.parentTurnId, panelMessages: turnItemData.panelMessages, fileEdits: turnItemData.fileEdits, diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift index 7e1fa514..c6275778 100644 --- a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -239,7 +239,7 @@ struct AgentConfigurationWidgetView: View { } private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo { - var lines = content.components(separatedBy: .newlines) + let lines = content.components(separatedBy: .newlines) var inFrontmatter = false var frontmatterEndIndex: Int? var modelLineIndex: Int? diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift index 6493f842..d68f297c 100644 --- a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift @@ -23,7 +23,7 @@ extension WidgetWindowsController { let state = store.withState { $0.panelState.agentConfigurationWidgetState } guard let noFocus = noFocus, !noFocus, - let focusedEditor = state.focusedEditor + state.focusedEditor != nil else { hideAgentConfigurationWidgetWindow() return diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift index ed7b4375..56ed2632 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift @@ -135,7 +135,7 @@ public struct CodeReviewPanelFeature { return .none - case let .close(id): + case .close(_): state.isPanelDisplayed = false state.closedByUser = true diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift index 071b1dd2..5ce61b0e 100644 --- a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift @@ -48,7 +48,6 @@ class NESMenuController: ObservableObject { let settingsItem = createSettingItem() let goToAcceptItem = createGoToAcceptItem() let rejectItem = createRejectItem() - let moreInfoItem = createGetMoreInfoItem() menu.addItem(titleItem) menu.addItem(NSMenuItem.separator()) @@ -56,8 +55,6 @@ class NESMenuController: ObservableObject { menu.addItem(NSMenuItem.separator()) menu.addItem(goToAcceptItem) menu.addItem(rejectItem) -// menu.addItem(NSMenuItem.separator()) -// menu.addItem(moreInfoItem) self.menu = menu return menu diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 5a2b9c0f..e3d19b8a 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -844,8 +844,8 @@ extension WidgetWindowsController { guard state.isPanelDisplayed, let comment = state.currentSelectedComment, await currentXcodeApp?.realtimeDocumentURL?.absoluteString == comment.uri, - let reviewWindowFittingSize = windows.codeReviewPanelWindow.contentView?.fittingSize - else { + windows.codeReviewPanelWindow.contentView?.fittingSize != nil + else { hideCodeReviewWindow() return } @@ -853,7 +853,7 @@ extension WidgetWindowsController { guard let originalContent = state.originalContent, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), let scrollViewRect = sourceEditorElement.parent?.rect, - let scrollScreenFrame = sourceEditorElement.parent?.maxIntersectionScreen?.frame, + sourceEditorElement.parent?.maxIntersectionScreen?.frame != nil, let currentContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute) else { return } diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 0a30d46d..00000000 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "CopilotforXcode-Icon@16w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "CopilotforXcode-Icon@16w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "CopilotforXcode-Icon@32w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "CopilotforXcode-Icon@32w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "CopilotforXcode-Icon@128w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "CopilotforXcode-Icon@128w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "CopilotforXcode-Icon@256w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "CopilotforXcode-Icon@256w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "CopilotforXcode-Icon@512w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "CopilotforXcode-Icon@512w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png deleted file mode 100644 index 3ee52427..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png deleted file mode 100644 index 88b20d1d..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png deleted file mode 100644 index 2bb554dc..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png deleted file mode 100644 index ce02bac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png deleted file mode 100644 index 7674f663..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png deleted file mode 100644 index fc705969..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png deleted file mode 100644 index ce02bac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png deleted file mode 100644 index 4d52c81b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png deleted file mode 100644 index fc705969..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png deleted file mode 100644 index 54da6e3f..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..ee2a2c8e --- /dev/null +++ b/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFE", + "green" : "0xF8", + "red" : "0xF5" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x4D", + "green" : "0x32", + "red" : "0x25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json new file mode 100644 index 00000000..fa914237 --- /dev/null +++ b/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFC", + "green" : "0xD6", + "red" : "0xC2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x8F", + "green" : "0x53", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json b/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json deleted file mode 100644 index 2e35661e..00000000 --- a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "copilot.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg b/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg deleted file mode 100644 index 8284dce7..00000000 --- a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/Server/package-lock.json b/Server/package-lock.json index 224f0112..72d6707e 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,9 +8,9 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "1.457.0", - "@github/copilot-language-server-darwin-arm64": "1.457.0", - "@github/copilot-language-server-darwin-x64": "1.457.0", + "@github/copilot-language-server": "1.465.5", + "@github/copilot-language-server-darwin-arm64": "1.465.5", + "@github/copilot-language-server-darwin-x64": "1.465.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.457.0.tgz", - "integrity": "sha512-P+hNX0zPN5+B6zgXPhR6QmUpofV9j9ZswSVxatOKPlaB5KKwGbmkzvrxUPXBRu0eMXCEqOOqtEJ/HBwReaUhkg==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.465.5.tgz", + "integrity": "sha512-dT0/MWx9wfImhQzNgVMIwWW81MkJBd0HtctYtmg2/nITyDsaSSjQLkYryzcsMMBx98JYwnyldQQc1QFx7q6mJw==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,18 +49,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.457.0", - "@github/copilot-language-server-darwin-x64": "1.457.0", - "@github/copilot-language-server-linux-arm64": "1.457.0", - "@github/copilot-language-server-linux-x64": "1.457.0", - "@github/copilot-language-server-win32-arm64": "1.457.0", - "@github/copilot-language-server-win32-x64": "1.457.0" + "@github/copilot-language-server-darwin-arm64": "1.465.5", + "@github/copilot-language-server-darwin-x64": "1.465.5", + "@github/copilot-language-server-linux-arm64": "1.465.5", + "@github/copilot-language-server-linux-x64": "1.465.5", + "@github/copilot-language-server-win32-arm64": "1.465.5", + "@github/copilot-language-server-win32-x64": "1.465.5" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.457.0.tgz", - "integrity": "sha512-mzeKomqU9NZswkGe6LxMrpfm+jUDBV5i6Al+6HRXkEzxsyOdY7FksqAIspwmqzpiohZ9ObwxUbp7RpcQY0wJCw==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.465.5.tgz", + "integrity": "sha512-yi4QvjsLDw4NiKnnJghYuGD00veI+4+6mtp6S0E3OWodWk3jdTPmmSOrgp049f2Dok5fb+Xg28Zau5XZy3FU/A==", "cpu": [ "arm64" ], @@ -70,9 +70,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.457.0.tgz", - "integrity": "sha512-CTge6InxrUFbWj3bik5jYfUjhqr08Za37an/aAHuTRp++DvoEjIl9eoJpQjdeu6hR+pRqX4TCWqDWewCjNIOuQ==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.465.5.tgz", + "integrity": "sha512-SsYnfyTb7/8INZfxGnfVsOTVuzaWeTz02HMxzKPdk/VAdDF5bTlX5Fy2wW3vTJuGSAWDDz0yCTj9pEsP3hU4CQ==", "cpu": [ "x64" ], @@ -82,9 +82,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.457.0.tgz", - "integrity": "sha512-y/T5jZL3UncYCkTD6pb3xqcAMfNMyOJc7fiMAMs91v33nkFx2qdoJoEt1DapVVMflX+rHUIrnIzunqjt3SpoKg==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.465.5.tgz", + "integrity": "sha512-qTVHFJrrlqm5dDUDanaot1KFh4aI0bRxrhYRss+/0aXPV+FhlfurxlQ9y44bWo3Bj3mBMs2Xn6c5F1BxcAFeXw==", "cpu": [ "arm64" ], @@ -95,9 +95,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.457.0.tgz", - "integrity": "sha512-dBmt7qETR5Xf2IzdVBtBw38VHQsOq4cf8Nxv9VdzjkV+qjSoS1Tsf7lYKR3O1uz6MKhJAS0spcy4c7soWOztRA==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.465.5.tgz", + "integrity": "sha512-aAJB5uYLwOdvbXSKLkroDFLaL3WBE/zwR2GVLdwL3rvW7/oR/u5GkzfLzYDSH8+8d4qJpNQWQN+71EbomHd24w==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-arm64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.457.0.tgz", - "integrity": "sha512-ojEYwoa2Gq3DPkrfHq0lKt3dV8VZ0RcI2pkTCndSmtDP93bDr3Y7f0pBj4qh41IQG8iZwjmRAydIKZJ0rIldTQ==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.465.5.tgz", + "integrity": "sha512-jLPMguKEWg8qFB+6acEGyBnPNOza5m6Pws4OZnuFmj4aazrt6Bw40M8KhcQ9XCdp8phc4xt67SNOrCrITkOcIw==", "cpu": [ "arm64" ], @@ -121,9 +121,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.457.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.457.0.tgz", - "integrity": "sha512-VVdcoCuzoWeCfvcgPQwBuP6ZTkcJm13zxhoxVmxWH3TaCrGaTY2S9xKX5ZoATcLvMyE6b3phKU3px3lGaZr2Aw==", + "version": "1.465.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.465.5.tgz", + "integrity": "sha512-kVBnzO24HAaQZflC/lGG9bWJ3c4aJPLQFBHQUkXPWE+s0SjPX4T+cdiH2ZAPGn90m2PxhJ9EsknHKIJHALFOrA==", "cpu": [ "x64" ], diff --git a/Server/package.json b/Server/package.json index a669cca8..03e3d6e9 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,9 +7,9 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "1.457.0", - "@github/copilot-language-server-darwin-arm64": "1.457.0", - "@github/copilot-language-server-darwin-x64": "1.457.0", + "@github/copilot-language-server": "1.465.5", + "@github/copilot-language-server-darwin-arm64": "1.465.5", + "@github/copilot-language-server-darwin-x64": "1.465.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Package.swift b/Tool/Package.swift index 64c6d2fb..cf45e947 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -66,7 +66,8 @@ let package = Package( .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]), .library(name: "HostAppActivator", targets: ["HostAppActivator"]), .library(name: "AppKitExtension", targets: ["AppKitExtension"]), - .library(name: "GitHelper", targets: ["GitHelper"]) + .library(name: "GitHelper", targets: ["GitHelper"]), + .library(name: "NotificationCenterCoordinator", targets: ["NotificationCenterCoordinator"]) ], dependencies: [ // TODO: Update LanguageClient some day. @@ -189,6 +190,7 @@ let package = Package( dependencies: [ "SuggestionBasic", "SuggestionProvider", + "TelemetryServiceProvider", "Workspace", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -266,7 +268,8 @@ let package = Package( dependencies: [ "Logger", "Status", - .product(name: "SQLite", package: "SQLite.Swift") + .product(name: "SQLite", package: "SQLite.Swift"), + .product(name: "JSONRPC", package: "JSONRPC") ] ), @@ -304,6 +307,8 @@ let package = Package( // MARK: - GitHub Copilot + .target(name: "NotificationCenterCoordinator"), + .target( name: "GitHubCopilotService", dependencies: [ @@ -320,6 +325,7 @@ let package = Package( "Workspace", "Persist", "SuggestionProvider", + "NotificationCenterCoordinator", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 677a8264..6d0a2584 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -373,6 +373,8 @@ public extension AXUIElement { } #if hasFeature(RetroactiveAttribute) +extension AXError: @retroactive _BridgedNSError {} +extension AXError: @retroactive _ObjectiveCBridgeableError {} extension AXError: @retroactive Error {} #else extension AXError: Error {} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 14625052..fbf5852b 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -211,7 +211,19 @@ public final class BuiltinExtensionConversationServiceProvider< Logger.service.error("Could not get active workspace info") return nil } - + return (try? await conversationService.reviewChanges(workspace: workspaceInfo, changes: changes)) } + + public func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + return try? await conversationService.generateThinkingTitle(workspace: workspaceInfo, params: params) + } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index d988a91e..bdfe7672 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -22,14 +22,14 @@ public extension ChatMemory { if !message.editAgentRounds.isEmpty { var parentRounds = parentMessage.editAgentRounds - + if let lastParentRoundIndex = parentRounds.indices.last { var existingSubRounds = parentRounds[lastParentRoundIndex].subAgentRounds ?? [] - + for messageRound in message.editAgentRounds { if let subIndex = existingSubRounds.firstIndex(where: { $0.roundId == messageRound.roundId }) { existingSubRounds[subIndex].reply = existingSubRounds[subIndex].reply + messageRound.reply - + mergeThinking(into: &existingSubRounds[subIndex].thinking, from: messageRound.thinking) if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty { var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? [] for newToolCall in messageToolCalls { @@ -77,7 +77,7 @@ public extension ChatMemory { parentMessage.editAgentRounds = parentRounds } } - + history[parentIndex] = parentMessage } else if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) @@ -137,15 +137,17 @@ extension ChatMessage { self.steps = mergedSteps } - + if !message.editAgentRounds.isEmpty { let mergedAgentRounds = mergeEditAgentRounds( - oldRounds: self.editAgentRounds, + oldRounds: self.editAgentRounds, newRounds: message.editAgentRounds ) - + self.editAgentRounds = mergedAgentRounds } + + mergeThinking(into: &self.thinking, from: message.thinking) self.parentTurnId = message.parentTurnId ?? self.parentTurnId @@ -166,7 +168,9 @@ extension ChatMessage { for newRound in newRounds { if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply - + + mergeThinking(into: &mergedAgentRounds[index].thinking, from: newRound.thinking) + if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] for newToolCall in newRound.toolCalls! { @@ -266,3 +270,34 @@ extension ChatMessage { return edits } } + +/// Merges incoming thinking deltas into an accumulated thinking array. Deltas are matched by +/// `clientEntryId` (a stable client-generated key), so server delta `id` churn does not split a + /// streaming block. New entries (different `clientEntryId`) append; for the same entry, text + /// concatenates, `id` is replaced with the latest server value, `encrypted` and `title` keep + /// their existing values when the incoming delta omits them, and `isComplete` remains `true` + /// once any delta marks it complete. +internal func mergeThinking(into accumulator: inout [MessageThinking], from incoming: [MessageThinking]) { + for newThinking in incoming { + let hasNewText = !(newThinking.text?.allSatisfy { $0.isEmpty } ?? true) + let hasNewTitle = newThinking.title != nil + + if let index = accumulator.firstIndex(where: { $0.clientEntryId == newThinking.clientEntryId }) { + let existing = accumulator[index] + var mergedText = existing.text ?? [] + if let new = newThinking.text { + mergedText.append(contentsOf: new) + } + accumulator[index] = MessageThinking( + clientEntryId: existing.clientEntryId, + id: newThinking.id, + text: mergedText.isEmpty ? nil : mergedText, + encrypted: newThinking.encrypted ?? existing.encrypted, + title: newThinking.title ?? existing.title, + isComplete: newThinking.isComplete || existing.isComplete + ) + } else if hasNewText || hasNewTitle { + accumulator.append(newThinking) + } + } +} diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 82337095..0ee0fd1d 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -158,7 +158,9 @@ public struct ChatMessage: Equatable, Codable { /// The steps of conversation progress public var steps: [ConversationProgressStep] - + + public var thinking: [MessageThinking] + public var editAgentRounds: [AgentRound] public var parentTurnId: String? @@ -198,6 +200,7 @@ public struct ChatMessage: Equatable, Codable { rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + thinking: [MessageThinking] = [], parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], codeReviewRound: CodeReviewRound? = nil, @@ -221,6 +224,7 @@ public struct ChatMessage: Equatable, Codable { self.errorMessages = errorMessages self.rating = rating self.steps = steps + self.thinking = thinking self.editAgentRounds = editAgentRounds self.parentTurnId = parentTurnId self.panelMessages = panelMessages @@ -264,6 +268,7 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + thinking: [MessageThinking] = [], parentTurnId: String? = nil, codeReviewRound: CodeReviewRound? = nil, fileEdits: [FileEdit] = [], @@ -283,6 +288,7 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: suggestedTitle, steps: steps, editAgentRounds: editAgentRounds, + thinking: thinking, parentTurnId: parentTurnId, codeReviewRound: codeReviewRound, fileEdits: fileEdits, diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 0612cca5..bcaeac23 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -19,7 +19,7 @@ public struct ChatTabPreviewInfo: Identifiable, Equatable, Codable { /// The information of a tab. @ObservableState -public struct ChatTabInfo: Identifiable, Equatable, Codable { +public struct ChatTabInfo: Identifiable, Equatable, Hashable, Codable { public var id: String public var title: String? = nil public var isTitleSet: Bool { diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index bb2b0573..0094483e 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -20,6 +20,7 @@ public protocol ConversationServiceType { workspace: WorkspaceInfo, changes: [ReviewChangesParams.Change] ) async throws -> CodeReviewResult? + func generateThinkingTitle(workspace: WorkspaceInfo, params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public protocol ConversationServiceProvider { @@ -36,6 +37,7 @@ public protocol ConversationServiceProvider { func agents() async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? + func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public struct ConversationFileReference: Hashable, Codable, Equatable { @@ -451,6 +453,134 @@ public struct ConversationProgressStep: Codable, Equatable, Identifiable { } } +public struct Thinking: Codable, Equatable { + public let id: String + public let text: [String]? + public let encrypted: String? + + public init(id: String, text: [String]?, encrypted: String?) { + self.id = id + self.text = text + self.encrypted = encrypted + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) + text = try container.decodeStringOrArray(forKey: .text) + } +} + +/// Internal, message-level thinking state. +/// +/// Distinct from the wire/server `Thinking` payload above: that type carries deltas +/// streamed from the LSP, while `MessageThinking` is the accumulated UI state stored on +/// a `ChatMessage` (or `AgentRound`) and persisted across sessions. `isComplete` is a +/// UI/state flag the server never sends — it's set when a thinking block ends. +public struct MessageThinking: Codable, Equatable { + /// Stable client-generated key for this entry. Survives server delta `id` churn (e.g. + /// CodeX models emit a new `id` per delta) and is what the seal/title-attach code paths + /// look up. Persisted; older saved messages without it get a fresh UUID on decode. + public var clientEntryId: UUID + public var id: String + public var text: [String]? + public var encrypted: String? + public var title: String? + public var isComplete: Bool + + public init( + clientEntryId: UUID = UUID(), + id: String, + text: [String]?, + encrypted: String?, + title: String? = nil, + isComplete: Bool = false + ) { + self.clientEntryId = clientEntryId + self.id = id + self.text = text + self.encrypted = encrypted + self.title = title + self.isComplete = isComplete + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + clientEntryId = try container.decodeIfPresent(UUID.self, forKey: .clientEntryId) ?? UUID() + id = try container.decode(String.self, forKey: .id) + encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) + title = try container.decodeIfPresent(String.self, forKey: .title) + isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false + text = try container.decodeStringOrArray(forKey: .text) + } + + public init(from server: Thinking, clientEntryId: UUID = UUID(), isComplete: Bool = false) { + self.clientEntryId = clientEntryId + self.id = server.id + self.text = server.text + self.encrypted = server.encrypted + self.title = nil + self.isComplete = isComplete + } + + /// Parses thinking text into title-paired sections. + /// + /// Each "title-only" line (`**Title**` on its own) starts a new section. All lines that + /// follow up to the next title (or end of text) become that section's body. Lines before + /// any title go into a leading section with `title == nil`. + public static func parseSections(from raw: String) -> [ThinkingSection] { + if raw.isEmpty { return [] } + var sections: [ThinkingSection] = [] + var currentTitle: String? = nil + var currentBody: [String] = [] + + func flush() { + let body = currentBody.joined().trimmingCharacters(in: .whitespacesAndNewlines) + if currentTitle != nil || !body.isEmpty { + sections.append(ThinkingSection(title: currentTitle, body: body)) + } + } + + for line in raw.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("**"), trimmed.hasSuffix("**"), trimmed.count > 4 { + let inner = String(trimmed.dropFirst(2).dropLast(2)) + if !inner.isEmpty, !inner.contains("*") { + flush() + currentTitle = inner + currentBody = [] + continue + } + } + currentBody.append(line + "\n") + } + flush() + return sections + } +} + +public struct ThinkingSection: Equatable { + public let title: String? + public let body: String + + public init(title: String?, body: String) { + self.title = title + self.body = body + } +} + +public extension KeyedDecodingContainer { + /// Decodes a value that the wire format may emit as either a single `String` or `[String]`, + /// normalizing to `[String]?`. Returns `nil` if the key is absent. + func decodeStringOrArray(forKey key: Key) throws -> [String]? { + if let single = try? decode(String.self, forKey: key) { + return [single] + } + return try decodeIfPresent([String].self, forKey: key) + } +} + public struct ContextSizeInfo: Codable, Equatable { public let totalTokenLimit: Int public let systemPromptTokens: Int diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift index 69124626..95579d41 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift @@ -7,12 +7,29 @@ public struct AgentRound: Codable, Equatable { public var reply: String public var toolCalls: [AgentToolCall]? public var subAgentRounds: [AgentRound]? - - public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = []) { + public var thinking: [MessageThinking] + + public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = [], thinking: [MessageThinking] = []) { self.roundId = roundId self.reply = reply self.toolCalls = toolCalls self.subAgentRounds = subAgentRounds + self.thinking = thinking + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + roundId = try container.decode(Int.self, forKey: .roundId) + reply = try container.decode(String.self, forKey: .reply) + toolCalls = try container.decodeIfPresent([AgentToolCall].self, forKey: .toolCalls) + subAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .subAgentRounds) + if let array = try? container.decodeIfPresent([MessageThinking].self, forKey: .thinking) { + thinking = array + } else if let single = try? container.decodeIfPresent(MessageThinking.self, forKey: .thinking) { + thinking = [single] + } else { + thinking = [] + } } } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index f688777a..cd150aa6 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -515,6 +515,20 @@ public struct ActionCommand: Codable, Equatable, Hashable { // MARK: - Copilot Code Review +public struct GenerateThinkingTitleParams: Codable { + public var thinkingContent: String? + public var extractedTitles: [String]? + + public init(thinkingContent: String? = nil, extractedTitles: [String]? = nil) { + self.thinkingContent = thinkingContent + self.extractedTitles = extractedTitles + } +} + +public struct GenerateThinkingTitleResponse: Codable { + public var title: String +} + public struct ReviewChangesParams: Codable, Equatable { public struct Change: Codable, Equatable { public let uri: DocumentUri @@ -542,8 +556,8 @@ public struct ReviewChangesParams: Codable, Equatable { } public struct ReviewComment: Codable, Equatable, Hashable { - // Self-defined `id` for using in comment operation. Add an init value to bypass decoding - public let id: String = UUID().uuidString + // Self-defined `id` for using in comment operation. Generated when missing from payload. + public let id: String public let uri: DocumentUri public let range: LSPRange public let message: String @@ -552,7 +566,7 @@ public struct ReviewComment: Codable, Equatable, Hashable { // enum: low, medium, high public let severity: String public let suggestion: String? - + public init( uri: DocumentUri, range: LSPRange, @@ -561,6 +575,7 @@ public struct ReviewComment: Codable, Equatable, Hashable { severity: String, suggestion: String? ) { + self.id = UUID().uuidString self.uri = uri self.range = range self.message = message @@ -568,6 +583,21 @@ public struct ReviewComment: Codable, Equatable, Hashable { self.severity = severity self.suggestion = suggestion } + + private enum CodingKeys: String, CodingKey { + case id, uri, range, message, kind, severity, suggestion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + self.uri = try container.decode(DocumentUri.self, forKey: .uri) + self.range = try container.decode(LSPRange.self, forKey: .range) + self.message = try container.decode(String.self, forKey: .message) + self.kind = try container.decode(String.self, forKey: .kind) + self.severity = try container.decode(String.self, forKey: .severity) + self.suggestion = try container.decodeIfPresent(String.self, forKey: .suggestion) + } } public struct CodeReviewResult: Codable, Equatable { diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift index ad2de6a7..05e811bf 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift @@ -4,6 +4,7 @@ import Combine import Logger import AppKit import LanguageServerProtocol +import NotificationCenterCoordinator import UserNotifications public protocol ShowMessageRequestHandler { @@ -13,28 +14,10 @@ public protocol ShowMessageRequestHandler { ) } -public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHandler, UNUserNotificationCenterDelegate { +public final class ShowMessageRequestHandlerImpl: ShowMessageRequestHandler { public static let shared = ShowMessageRequestHandlerImpl() - - private var isNotificationSetup = false - - private override init() { - super.init() - } - - @MainActor - private func setupNotificationCenterIfNeeded() async { - guard !isNotificationSetup else { return } - guard Bundle.main.bundleIdentifier != nil else { - // Skip notification setup in test environment - return - } - - isNotificationSetup = true - UNUserNotificationCenter.current().delegate = self - _ = try? await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert, .sound]) - } + + private init() {} public func handleShowMessageRequest( _ request: ShowMessageRequest, @@ -43,8 +26,8 @@ public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHa guard let params = request.params else { return } Logger.gitHubCopilot.debug("Received Show Message Request: \(params)") Task { @MainActor in - await setupNotificationCenterIfNeeded() - + await NotificationCenterCoordinator.shared.setupIfNeeded() + let actionCount = params.actions?.count ?? 0 // Use notification for messages with no action, alert for messages with actions @@ -103,16 +86,4 @@ public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHa return actions[buttonIndex] } - - // MARK: - UNUserNotificationCenterDelegate - - // This method is called when a notification is delivered while the app is in the foreground - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - // Show the notification banner even when app is in foreground - completionHandler([.banner, .list, .badge, .sound]) - } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 4c8b2721..ad691ce7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -231,6 +231,9 @@ class CopilotLocalProcessServer { case "$/copilot/compressionCompleted": notificationPublisher.send(anyNotification) return true + case "$/copilot/rateLimitWarning": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 1f952728..284c27c7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -718,6 +718,20 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/listApiKeys", dict, ClientRequest.NullHandler) } } + + // MARK: Thinking + struct GenerateThinkingTitle: GitHubCopilotRequestType { + typealias Response = GenerateThinkingTitleResponse + + var params: GenerateThinkingTitleParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("thinking/generateTitle", dict, ClientRequest.NullHandler) + } + } + } // MARK: Notifications diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index 85d199b2..72fbbc00 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -35,6 +35,7 @@ public struct ConversationProgressReport: BaseConversationProgress { public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? public let parentTurnId: String? + public let thinking: Thinking? public let contextSize: ContextSizeInfo? } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 9770d70d..ef45792c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -101,6 +101,7 @@ public protocol GitHubCopilotConversationServiceType { func models() async throws -> [CopilotModel] func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] + func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse } protocol GitHubCopilotLSP { @@ -810,6 +811,11 @@ public final class GitHubCopilotService: } } + @GitHubCopilotSuggestionActor + public func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse { + try await sendRequest(GitHubCopilotRequest.GenerateThinkingTitle(params: params)) + } + @GitHubCopilotSuggestionActor public func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] { do { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index c1cf94a5..bbc7c4c6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -15,6 +15,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared + var rateLimitNotifier: RateLimitNotifier = RateLimitNotifierImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -67,6 +68,15 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { compressionHandler.onCompressionCompleted.send(payload) } break + case "$/copilot/rateLimitWarning": + if let data = try? JSONEncoder().encode(notification.params), + let params = try? JSONDecoder().decode( + RateLimitWarningParams.self, + from: data + ) { + rateLimitNotifier.handleRateLimitWarning(params) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift index 4153e1ce..62f12e6f 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -153,5 +153,13 @@ public final class GitHubCopilotConversationService: ConversationServiceType { workspaceFolders: getWorkspaceFolders(workspace: workspace)) ) } + + public func generateThinkingTitle( + workspace: WorkspaceInfo, + params: GenerateThinkingTitleParams + ) async throws -> GenerateThinkingTitleResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + return try await service.generateThinkingTitle(params: params) + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift new file mode 100644 index 00000000..30bb3f25 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift @@ -0,0 +1,106 @@ +import AppKit +import Combine +import Foundation +import Logger +import NotificationCenterCoordinator +import UserNotifications + +public struct UsageRateLimit: Hashable, Codable { + public var entitlement: Int + public var percentRemaining: Double + public var resetDate: String +} + +public struct RateLimitWarningParams: Hashable, Codable { + public var type: String // "weekly" or "session" + public var rateLimit: UsageRateLimit + public var message: String +} + +public protocol RateLimitNotifier { + func handleRateLimitWarning(_ params: RateLimitWarningParams) +} + +public class RateLimitNotifierImpl: NSObject, RateLimitNotifier, ObservableObject { + public static let shared = RateLimitNotifierImpl() + + @Published public var currentWarning: RateLimitWarningParams? + + private static let categoryIdentifier = "rateLimitWarningCategory" + private static let learnMoreActionIdentifier = "rateLimitLearnMoreAction" + private static let learnMoreURL = URL( + string: "https://aka.ms/github-copilot-rate-limit-error" + )! + + private var isCategoryRegistered = false + + private override init() { + super.init() + } + + private func registerCategoryIfNeeded() { + guard !isCategoryRegistered else { return } + isCategoryRegistered = true + + let learnMoreAction = UNNotificationAction( + identifier: Self.learnMoreActionIdentifier, + title: "Learn more", + options: [.foreground] + ) + let category = UNNotificationCategory( + identifier: Self.categoryIdentifier, + actions: [learnMoreAction], + intentIdentifiers: [], + options: [] + ) + + NotificationCenterCoordinator.shared.register( + category: category, + handler: { response in + if response.actionIdentifier == Self.learnMoreActionIdentifier { + NSWorkspace.shared.open(Self.learnMoreURL) + } + }, + for: Self.categoryIdentifier + ) + } + + public func handleRateLimitWarning(_ params: RateLimitWarningParams) { + DispatchQueue.main.async { [weak self] in + self?.currentWarning = params + } + + Task { @MainActor in + await NotificationCenterCoordinator.shared.setupIfNeeded() + self.registerCategoryIfNeeded() + await sendAppleNotification(params) + } + } + + public func dismissWarning() { + DispatchQueue.main.async { [weak self] in + self?.currentWarning = nil + } + } + + @MainActor + private func sendAppleNotification(_ params: RateLimitWarningParams) async { + let content = UNMutableNotificationContent() + content.title = "GitHub Copilot for Xcode" + content.body = params.message + content.sound = .default + content.categoryIdentifier = Self.categoryIdentifier + + let request = UNNotificationRequest( + identifier: "rateLimitWarning-\(UUID().uuidString)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show rate limit notification: \(error)") + } + } +} diff --git a/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift b/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift new file mode 100644 index 00000000..4e69163e --- /dev/null +++ b/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift @@ -0,0 +1,82 @@ +import Foundation +import UserNotifications + +/// A single shared delegate for `UNUserNotificationCenter`. +/// +/// `UNUserNotificationCenter` has only one `delegate` and one set of categories. +/// Multiple features (rate limit warnings, show-message requests, etc.) need to +/// post notifications and handle action taps, so they register handlers here +/// keyed by category identifier instead of each assigning themselves as the +/// delegate. +public final class NotificationCenterCoordinator: NSObject, UNUserNotificationCenterDelegate { + public static let shared = NotificationCenterCoordinator() + + public typealias ActionHandler = (UNNotificationResponse) -> Void + + private var isNotificationSetup = false + private var categories: [String: UNNotificationCategory] = [:] + private var actionHandlers: [String: ActionHandler] = [:] + private let lock = NSLock() + + private override init() { + super.init() + } + + /// Ensures the notification center delegate is set and authorization has + /// been requested. Safe to call multiple times. + @MainActor + public func setupIfNeeded() async { + guard !isNotificationSetup else { return } + guard Bundle.main.bundleIdentifier != nil else { + // Skip notification setup in test environment. + return + } + isNotificationSetup = true + UNUserNotificationCenter.current().delegate = self + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } + + /// Registers a category (optional) and an action handler for notifications + /// whose `categoryIdentifier` matches. + public func register( + category: UNNotificationCategory?, + handler: @escaping ActionHandler, + for categoryIdentifier: String + ) { + lock.lock() + if let category { + categories[categoryIdentifier] = category + } + actionHandlers[categoryIdentifier] = handler + let allCategories = Set(categories.values) + lock.unlock() + + UNUserNotificationCenter.current().setNotificationCategories(allCategories) + } + + // MARK: - UNUserNotificationCenterDelegate + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .badge, .sound]) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let categoryIdentifier = response.notification.request.content.categoryIdentifier + lock.lock() + let handler = actionHandlers[categoryIdentifier] + lock.unlock() + Task { @MainActor in + handler?(response) + completionHandler() + } + } +} diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift index 2ec2f53c..765d35e4 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift @@ -80,8 +80,6 @@ public final class ConversationStorage: ConversationStorageProtocol { try withDBTransaction { db in - let now = Date().timeIntervalSince1970 - for operation in request.operations { switch operation { case .upsertConversation(let conversationItems): @@ -137,7 +135,7 @@ public final class ConversationStorage: ConversationStorageProtocol { let table = turnTable.table let column = turnTable.column - var query = table + let query = table .filter(column.conversationID == conversationID) .order(column.rowID.asc) let rowIterator = try db.prepareRowIterator(query) diff --git a/Tool/Sources/Preferences/Types/Locale.swift b/Tool/Sources/Preferences/Types/Locale.swift index 6b50d82d..6bd25fe2 100644 --- a/Tool/Sources/Preferences/Types/Locale.swift +++ b/Tool/Sources/Preferences/Types/Locale.swift @@ -2,14 +2,14 @@ import Foundation public extension Locale { static var availableLocalizedLocales: [String] { - let localizedLocales = Locale.isoLanguageCodes.compactMap { - Locale(identifier: "en-US").localizedString(forLanguageCode: $0) + let localizedLocales = Locale.LanguageCode.isoLanguageCodes.compactMap { + Locale(identifier: "en-US").localizedString(forLanguageCode: $0.identifier) } .sorted() return localizedLocales } var languageName: String { - localizedString(forLanguageCode: languageCode ?? "") ?? "" + localizedString(forLanguageCode: language.languageCode?.identifier ?? "") ?? "" } } diff --git a/Tool/Sources/TelemetryService/TelemetryCleaner.swift b/Tool/Sources/TelemetryService/TelemetryCleaner.swift index 069ad843..5dcd6987 100644 --- a/Tool/Sources/TelemetryService/TelemetryCleaner.swift +++ b/Tool/Sources/TelemetryService/TelemetryCleaner.swift @@ -69,7 +69,7 @@ public struct TelemetryCleaner { ("Email", "@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+") ] - var cleanedValue = value + let cleanedValue = value for (label, pattern) in patterns { if let regex = try? NSRegularExpression(pattern: pattern) { if regex.firstMatch( diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index a179bc83..9d262102 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -260,7 +260,6 @@ extension Workspace { // Handle empty old content (new file) if oldContent.isEmpty { - let endPosition = calculateEndPosition(content: oldContent) return [TextDocumentContentChangeEvent( range: LSPRange( start: Position(line: 0, character: 0), diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 03656855..d99a744f 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -153,7 +153,7 @@ public extension Filespace { /// - Returns: `true` if the nes suggestion is still valid @WorkspaceActor func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { - guard let presentingNESSuggestion else { return false } + guard presentingNESSuggestion != nil else { return false } let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition)