Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/auto-create-release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ jobs:
run: |
gh pr merge "${{ github.ref_name }}" \
--auto \
--merge \
--delete-branch
2 changes: 1 addition & 1 deletion CommunicationBridge/ServiceDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Copilot for Xcode/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
4 changes: 4 additions & 0 deletions Core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ let package = Package(
dependencies: [
"SuggestionWidget",
"SuggestionService",
"SuggestionInjector",
"ChatService",
"PromptToCodeService",
"ConversationTab",
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
306 changes: 250 additions & 56 deletions Core/Sources/ChatService/ChatService.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion Core/Sources/ConversationTab/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -50,6 +51,7 @@ public struct DisplayedChatMessage: Equatable {
suggestedTitle: String? = nil,
errorMessages: [String] = [],
steps: [ConversationProgressStep] = [],
thinking: [MessageThinking] = [],
editAgentRounds: [AgentRound] = [],
parentTurnId: String? = nil,
panelMessages: [CopilotShowMessageParams] = [],
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
82 changes: 70 additions & 12 deletions Core/Sources/ConversationTab/ChatPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ private let r: Double = 4
public struct ChatPanel: View {
@Perception.Bindable var chat: StoreOf<Chat>
@Namespace var inputAreaNamespace
@ObservedObject private var rateLimitNotifier = RateLimitNotifierImpl.shared

public var body: some View {
WithPerceptionTracking {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Chat>
@State var cancellable = Set<AnyCancellable>()
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
26 changes: 21 additions & 5 deletions Core/Sources/ConversationTab/Views/BotMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -90,21 +91,28 @@ 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
FunctionMessage(text: panelMessages[index].message, chat: chat)
}
}
}

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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ import SwiftUI
struct ProgressAgentRound: View {
let rounds: [AgentRound]
let chat: StoreOf<Chat>
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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
Loading
Loading