Skip to content

Commit 518e304

Browse files
committed
Merge branch 'feature/detached-chat-panel' into develop
2 parents d24251b + e34193f commit 518e304

13 files changed

Lines changed: 464 additions & 193 deletions

File tree

Core/Sources/AXExtension/AXUIElement.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ public extension AXUIElement {
3131
var description: String {
3232
(try? copyValue(key: kAXDescriptionAttribute)) ?? ""
3333
}
34+
35+
var label: String {
36+
(try? copyValue(key: kAXLabelValueAttribute)) ?? ""
37+
}
3438

3539
var isSourceEditor: Bool {
3640
description == "Source Editor"

Core/Sources/Environment/Environment.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ public enum Environment {
105105
}
106106
throw FailedToFetchFileURLError()
107107
}
108+
109+
public static var fetchFocusedElementURI: () async throws -> URL = {
110+
guard let xcode = ActiveApplicationMonitor.activeXcode
111+
?? ActiveApplicationMonitor.latestXcode
112+
else {
113+
throw FailedToFetchFileURLError()
114+
}
115+
116+
let application = AXUIElementCreateApplication(xcode.processIdentifier)
117+
let focusedElement = application.focusedElement
118+
if focusedElement?.description != "Source Editor" {
119+
let window = application.focusedWindow
120+
let id = window?.identifier.hashValue
121+
return URL(fileURLWithPath: "/xcode-focused-element/\(id ?? 0)")
122+
}
123+
124+
return try await fetchCurrentFileURL()
125+
}
108126

109127
public static var createAuthService: () -> CopilotAuthServiceType = {
110128
CopilotAuthService()
@@ -148,6 +166,15 @@ public enum Environment {
148166
.error("Trigger action \(name) failed: \(error.localizedDescription)")
149167
throw error
150168
}
169+
} else {
170+
struct CantRunCommand: Error, LocalizedError {
171+
let name: String
172+
var errorDescription: String? {
173+
"Can't run command \(name)."
174+
}
175+
}
176+
177+
throw CantRunCommand(name: name)
151178
}
152179
} else {
153180
/// check if menu is open, if not, click the menu item.

Core/Sources/Preferences/Keys.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ public struct UserDefaultPreferenceKeys {
175175
public var preferWidgetToStayInsideEditorWhenWidthGreaterThan: PreferWidgetToStayInsideEditorWhenWidthGreaterThan {
176176
.init()
177177
}
178+
179+
// MARK: - Chat Panel in a Separate Window
180+
181+
public struct ChatPanelInASeparateWindow: UserDefaultPreferenceKey {
182+
public let defaultValue = true
183+
public let key = "ChatPanelInASeparateWindow"
184+
}
185+
186+
public var chatPanelInASeparateWindow: ChatPanelInASeparateWindow {
187+
.init()
188+
}
178189
}
179190

180191
// MARK: - OpenAI Account Settings

Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,26 @@ struct PseudoCommandHandler {
6969
}
7070

7171
func handleCustomCommand(_ command: CustomCommand) async {
72-
guard let editor = await getEditorContent(sourceEditor: nil)
73-
else {
72+
guard let editor = await {
73+
if let it = await getEditorContent(sourceEditor: nil) {
74+
return it
75+
}
76+
switch command.feature {
77+
case .customChat:
78+
return .init(
79+
content: "",
80+
lines: [],
81+
uti: "",
82+
cursorPosition: .outOfScope,
83+
selections: [],
84+
tabSize: 0,
85+
indentSize: 0,
86+
usesTabsForIndentation: false
87+
)
88+
case .chatWithSelection, .promptToCode:
89+
return nil
90+
}
91+
}() else {
7492
do {
7593
try await Environment.triggerAction(command.name)
7694
} catch {

Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
5353
forFileAt: fileURL,
5454
editor: editor
5555
)
56+
57+
try Task.checkCancellation()
5658

5759
if filespace.presentingSuggestion != nil {
5860
presenter.presentSuggestion(fileURL: fileURL)
@@ -295,8 +297,7 @@ extension WindowBaseCommandHandler {
295297
name: command.name
296298
)
297299
case let .customChat(systemPrompt, prompt):
298-
try await startChatWithSelection(
299-
editor: editor,
300+
try await startChat(
300301
specifiedSystemPrompt: systemPrompt,
301302
extraSystemPrompt: nil,
302303
sendingMessageImmediately: prompt,
@@ -433,7 +434,7 @@ extension WindowBaseCommandHandler {
433434

434435
Task {
435436
let customCommandPrefix = {
436-
if let name { return "[\(name)]" }
437+
if let name { return "[\(name)] " }
437438
return ""
438439
}()
439440

@@ -442,23 +443,23 @@ extension WindowBaseCommandHandler {
442443
history.append(.init(
443444
role: .assistant,
444445
content: "",
445-
summary: "\(customCommandPrefix) System prompt is updated."
446+
summary: "\(customCommandPrefix)System prompt is updated."
446447
))
447448
}
448449
} else if !code.isEmpty, let selection = editor.selections.last {
449450
await chat.chatGPTService.mutateHistory { history in
450451
history.append(.init(
451452
role: .assistant,
452453
content: "",
453-
summary: "\(customCommandPrefix) Chatting about selected code in `\(fileURL.lastPathComponent)` from `\(selection.start.line + 1):\(selection.start.character + 1)` to `\(selection.end.line + 1):\(selection.end.character)`.\nThe code will persist in the conversation."
454+
summary: "\(customCommandPrefix)Chatting about selected code in `\(fileURL.lastPathComponent)` from `\(selection.start.line + 1):\(selection.start.character + 1)` to `\(selection.end.line + 1):\(selection.end.character)`.\nThe code will persist in the conversation."
454455
))
455456
}
456457
} else if !customCommandPrefix.isEmpty {
457458
await chat.chatGPTService.mutateHistory { history in
458459
history.append(.init(
459460
role: .assistant,
460461
content: "",
461-
summary: "\(customCommandPrefix) System prompt is updated."
462+
summary: "\(customCommandPrefix)System prompt is updated."
462463
))
463464
}
464465
}
@@ -470,4 +471,63 @@ extension WindowBaseCommandHandler {
470471

471472
presenter.presentChatRoom(fileURL: fileURL)
472473
}
474+
475+
private func startChat(
476+
specifiedSystemPrompt: String?,
477+
extraSystemPrompt: String?,
478+
sendingMessageImmediately: String?,
479+
name: String?
480+
) async throws {
481+
presenter.markAsProcessing(true)
482+
defer { presenter.markAsProcessing(false) }
483+
484+
let focusedElementURI = try await Environment.fetchFocusedElementURI()
485+
let language = UserDefaults.shared.value(for: \.chatGPTLanguage)
486+
487+
var systemPrompt = specifiedSystemPrompt ?? """
488+
\(language.isEmpty ? "" : "You must always reply in \(language)")
489+
You are a senior programmer, you will answer my questions concisely. If you are replying with code, embed the code in a code block in markdown.
490+
491+
You don't have any code in advance, ask me to provide it when needed.
492+
"""
493+
494+
if let extraSystemPrompt {
495+
systemPrompt += "\n\(extraSystemPrompt)"
496+
}
497+
498+
let chat = WidgetDataSource.shared.createChatIfNeeded(for: focusedElementURI)
499+
500+
await chat.mutateSystemPrompt(systemPrompt)
501+
502+
Task {
503+
let customCommandPrefix = {
504+
if let name { return "[\(name)] " }
505+
return ""
506+
}()
507+
508+
if specifiedSystemPrompt != nil {
509+
await chat.chatGPTService.mutateHistory { history in
510+
history.append(.init(
511+
role: .assistant,
512+
content: "",
513+
summary: "\(customCommandPrefix)System prompt is updated."
514+
))
515+
}
516+
} else if !customCommandPrefix.isEmpty {
517+
await chat.chatGPTService.mutateHistory { history in
518+
history.append(.init(
519+
role: .assistant,
520+
content: "",
521+
summary: "\(customCommandPrefix)System prompt is updated."
522+
))
523+
}
524+
}
525+
526+
if let sendingMessageImmediately, !sendingMessageImmediately.isEmpty {
527+
try await chat.send(content: sendingMessageImmediately)
528+
}
529+
}
530+
531+
presenter.presentChatRoom(fileURL: focusedElementURI)
532+
}
473533
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
private let r: Double = 8
5+
6+
@MainActor
7+
final class ChatWindowViewModel: ObservableObject {
8+
@Published var chat: ChatProvider?
9+
@Published var colorScheme: ColorScheme
10+
@Published var isPanelDisplayed = false
11+
@AppStorage(\.chatPanelInASeparateWindow) var chatPanelInASeparateWindow
12+
13+
public init(chat: ChatProvider? = nil, colorScheme: ColorScheme = .dark) {
14+
self.chat = chat
15+
self.colorScheme = colorScheme
16+
}
17+
}
18+
19+
struct ChatWindowView: View {
20+
@ObservedObject var viewModel: ChatWindowViewModel
21+
22+
var body: some View {
23+
Group {
24+
if let chat = viewModel.chat {
25+
ChatPanel(chat: chat)
26+
}
27+
}
28+
.opacity(viewModel.isPanelDisplayed ? 1 : 0)
29+
.frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight)
30+
.preferredColorScheme(viewModel.colorScheme)
31+
}
32+
}

Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -60,39 +60,16 @@ struct ChatPanelToolbar: View {
6060

6161
struct ChatPanelMessages: View {
6262
@ObservedObject var chat: ChatProvider
63-
@AppStorage(\.disableLazyVStack) var disableLazyVStack
64-
@State var height: Double = 0
65-
66-
struct HeightPreferenceKey: PreferenceKey {
67-
static var defaultValue: Double = 0
68-
static func reduce(value: inout Double, nextValue: () -> Double) {
69-
value = nextValue() + value
70-
}
71-
}
72-
73-
struct UpdateHeightModifier: ViewModifier {
74-
func body(content: Content) -> some View {
75-
content
76-
.background {
77-
GeometryReader { proxy in
78-
Color.clear
79-
.preference(key: HeightPreferenceKey.self, value: proxy.size.height)
80-
}
81-
}
82-
}
83-
}
84-
63+
8564
var body: some View {
8665
List {
8766
Group {
8867
Spacer()
89-
.modifier(UpdateHeightModifier())
9068

9169
if chat.isReceivingMessage {
9270
StopRespondingButton(chat: chat)
9371
.padding(.vertical, 4)
9472
.listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8))
95-
.modifier(UpdateHeightModifier())
9673
}
9774

9875
if chat.history.isEmpty {
@@ -102,7 +79,6 @@ struct ChatPanelMessages: View {
10279
.scaleEffect(x: -1, y: -1, anchor: .center)
10380
.foregroundStyle(.secondary)
10481
.listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8))
105-
.modifier(UpdateHeightModifier())
10682
}
10783

10884
ForEach(chat.history.reversed(), id: \.id) { message in
@@ -120,20 +96,14 @@ struct ChatPanelMessages: View {
12096
}
12197
}
12298
.listItemTint(.clear)
123-
.modifier(UpdateHeightModifier())
12499

125100
Spacer()
126-
.modifier(UpdateHeightModifier())
127101
}
128102
.scaleEffect(x: -1, y: 1, anchor: .center)
129103
}
130104
.id("\(chat.history.count), \(chat.isReceivingMessage)")
131105
.listStyle(.plain)
132-
.frame(idealHeight: max(50, height + 16))
133106
.scaleEffect(x: 1, y: -1, anchor: .center)
134-
.onPreferenceChange(HeightPreferenceKey.self) { newHeight in
135-
height = newHeight
136-
}
137107
}
138108
}
139109

Core/Sources/SuggestionWidget/SuggestionPanelView.swift

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ final class SuggestionPanelViewModel: ObservableObject {
2424
case suggestion
2525
case chat
2626
}
27+
28+
@AppStorage(\.chatPanelInASeparateWindow) var chatPanelInASeparateWindow
2729

2830
@Published var content: Content? {
2931
didSet {
@@ -67,6 +69,11 @@ final class SuggestionPanelViewModel: ObservableObject {
6769
}
6870

6971
func adjustActiveTabAndShowHideIfNeeded(tab: ActiveTab) {
72+
if chatPanelInASeparateWindow {
73+
activeTab = .suggestion
74+
return
75+
}
76+
7077
switch tab {
7178
case .suggestion:
7279
if content != nil {
@@ -124,14 +131,14 @@ struct SuggestionPanelView: View {
124131
}
125132
}
126133

127-
if let chat = viewModel.chat {
128-
if case .chat = viewModel.activeTab {
129-
ChatPanel(chat: chat)
130-
.frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
131-
.fixedSize(horizontal: false, vertical: true)
132-
.allowsHitTesting(viewModel.isPanelDisplayed)
133-
}
134-
}
134+
// if let chat = viewModel.chat {
135+
// if case .chat = viewModel.activeTab {
136+
// ChatPanel(chat: chat)
137+
// .frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
138+
// .fixedSize(horizontal: false, vertical: true)
139+
// .allowsHitTesting(viewModel.isPanelDisplayed)
140+
// }
141+
// }
135142
}
136143
.frame(maxWidth: .infinity)
137144

0 commit comments

Comments
 (0)