Skip to content

Commit b4ad9af

Browse files
committed
Add detach chat panel
1 parent d24251b commit b4ad9af

File tree

8 files changed

+142
-32
lines changed

8 files changed

+142
-32
lines changed

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/WindowBaseCommandHandler.swift

Lines changed: 2 additions & 0 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)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
11+
public init(chat: ChatProvider? = nil, colorScheme: ColorScheme = .dark) {
12+
self.chat = chat
13+
self.colorScheme = colorScheme
14+
}
15+
}
16+
17+
struct ChatWindowView: View {
18+
@ObservedObject var viewModel: ChatWindowViewModel
19+
20+
var body: some View {
21+
Group {
22+
if let chat = viewModel.chat {
23+
ChatPanel(chat: chat)
24+
}
25+
}
26+
.frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight)
27+
.preferredColorScheme(viewModel.colorScheme)
28+
}
29+
}

Core/Sources/SuggestionWidget/SuggestionPanelView.swift

Lines changed: 7 additions & 0 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 {

Core/Sources/SuggestionWidget/SuggestionWidgetController.swift

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,29 @@ public final class SuggestionWidgetController {
9292
return it
9393
}()
9494

95+
private lazy var chatWindow = {
96+
let it = CanBecomeKeyWindow(
97+
contentRect: .zero,
98+
styleMask: [.borderless, .resizable],
99+
backing: .buffered,
100+
defer: false
101+
)
102+
it.isReleasedWhenClosed = false
103+
it.isOpaque = false
104+
it.backgroundColor = .clear
105+
it.level = .floating
106+
it.hasShadow = true
107+
it.contentView = NSHostingView(
108+
rootView: ChatWindowView(viewModel: chatWindowViewModel)
109+
)
110+
it.setIsVisible(true)
111+
it.canBecomeKeyChecker = { true }
112+
return it
113+
}()
114+
95115
let widgetViewModel = WidgetViewModel()
96116
let suggestionPanelViewModel = SuggestionPanelViewModel()
117+
let chatWindowViewModel = ChatWindowViewModel()
97118

98119
private var presentationModeChangeObserver = UserDefaultsObserver()
99120
private var colorSchemeChangeObserver = UserDefaultsObserver()
@@ -102,7 +123,7 @@ public final class SuggestionWidgetController {
102123
private var sourceEditorMonitorTask: Task<Void, Error>?
103124
private var currentFileURL: URL?
104125
private var colorScheme: ColorScheme = .light
105-
126+
106127
public var onOpenChatClicked: () -> Void = {}
107128
public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in }
108129
public var dataSource: SuggestionWidgetDataSource?
@@ -198,30 +219,32 @@ public final class SuggestionWidgetController {
198219
)
199220
}
200221

201-
Task { @MainActor in
202-
var switchTask: Task<Void, Error>?
203-
suggestionPanelViewModel.requestApplicationPolicyUpdate = { viewModel in
204-
#warning("""
205-
TODO: There should be a better way for that
206-
Currently, we have to make the app an accessory so that we can type things in the chat mode.
207-
But in other modes, we want to keep it prohibited so the helper app won't take over the focus.
208-
""")
209-
switch (viewModel.activeTab, viewModel.content) {
210-
case (.chat, _), (.suggestion, .promptToCode):
211-
guard NSApp.activationPolicy() != .accessory else { return }
212-
switchTask?.cancel()
213-
NSApp.setActivationPolicy(.accessory)
214-
case (.suggestion, _):
215-
guard NSApp.activationPolicy() != .prohibited else { return }
216-
switchTask?.cancel()
217-
switchTask = Task {
218-
try await Environment.makeXcodeActive()
219-
try Task.checkCancellation()
220-
NSApp.setActivationPolicy(.prohibited)
221-
}
222-
}
223-
}
224-
}
222+
// Task { @MainActor in
223+
// var switchTask: Task<Void, Error>?
224+
// suggestionPanelViewModel.requestApplicationPolicyUpdate = { viewModel in
225+
// #warning("""
226+
// TODO: There should be a better way for that
227+
// Currently, we have to make the app an accessory so that we can type things in the
228+
// chat mode.
229+
// But in other modes, we want to keep it prohibited so the helper app won't take
230+
// over the focus.
231+
// """)
232+
// switch (viewModel.activeTab, viewModel.content) {
233+
// case (.chat, _), (.suggestion, .promptToCode):
234+
// guard NSApp.activationPolicy() != .accessory else { return }
235+
// switchTask?.cancel()
236+
// NSApp.setActivationPolicy(.accessory)
237+
// case (.suggestion, _):
238+
// guard NSApp.activationPolicy() != .prohibited else { return }
239+
// switchTask?.cancel()
240+
// switchTask = Task {
241+
// try await Environment.makeXcodeActive()
242+
// try Task.checkCancellation()
243+
// NSApp.setActivationPolicy(.prohibited)
244+
// }
245+
// }
246+
// }
247+
// }
225248
}
226249
}
227250

@@ -259,9 +282,14 @@ public extension SuggestionWidgetController {
259282
widgetViewModel.isProcessing = false
260283
Task {
261284
if let chat = await dataSource?.chatForFile(at: fileURL) {
285+
chatWindowViewModel.chat = chat
262286
suggestionPanelViewModel.chat = chat
263287
suggestionPanelViewModel.isPanelDisplayed = true
264288

289+
if UserDefaults.shared.value(for: \.chatPanelInASeparateWindow) {
290+
self.updateWindowLocation()
291+
}
292+
265293
Task { @MainActor in
266294
// looks like we need a delay.
267295
try await Task.sleep(nanoseconds: 150_000_000)
@@ -277,14 +305,14 @@ public extension SuggestionWidgetController {
277305
await updateContentForActiveEditor(fileURL: fileURL)
278306
}
279307
}
280-
308+
281309
func presentPromptToCode(fileURL: URL) {
282310
widgetViewModel.isProcessing = false
283311
Task {
284312
if let provider = await dataSource?.promptToCodeForFile(at: fileURL) {
285313
suggestionPanelViewModel.content = .promptToCode(provider)
286314
suggestionPanelViewModel.isPanelDisplayed = true
287-
315+
288316
Task { @MainActor in
289317
// looks like we need a delay.
290318
try await Task.sleep(nanoseconds: 150_000_000)
@@ -293,7 +321,7 @@ public extension SuggestionWidgetController {
293321
}
294322
}
295323
}
296-
324+
297325
func discardPromptToCode(fileURL: URL) {
298326
widgetViewModel.isProcessing = false
299327
Task {
@@ -427,6 +455,14 @@ extension SuggestionWidgetController {
427455
hide()
428456
return
429457
}
458+
459+
let detachChat = UserDefaults.shared.value(for: \.chatPanelInASeparateWindow)
460+
461+
if detachChat {
462+
chatWindow.alphaValue = chatWindowViewModel.chat != nil ? 1 : 0
463+
} else {
464+
chatWindow.alphaValue = 0
465+
}
430466

431467
if let xcode = ActiveApplicationMonitor.activeXcode {
432468
let application = AXUIElementCreateApplication(xcode.processIdentifier)
@@ -461,6 +497,10 @@ extension SuggestionWidgetController {
461497
tabWindow.setFrame(result.tabFrame, display: false, animate: animated)
462498
suggestionPanelViewModel.alignTopToAnchor = result.alignPanelTopToAnchor
463499
}
500+
501+
if chatWindow.alphaValue == 0 {
502+
chatWindow.setFrame(panelWindow.frame, display: false, animate: false)
503+
}
464504

465505
if panelWindow.alphaValue != 1 {
466506
panelWindow.alphaValue = 1
@@ -484,6 +524,7 @@ extension SuggestionWidgetController {
484524
return try? await Environment.fetchCurrentFileURL()
485525
}() else {
486526
suggestionPanelViewModel.content = nil
527+
chatWindowViewModel.chat = nil
487528
suggestionPanelViewModel.chat = nil
488529
return
489530
}
@@ -492,10 +533,14 @@ extension SuggestionWidgetController {
492533
if suggestionPanelViewModel.chat?.id != chat.id {
493534
suggestionPanelViewModel.chat = chat
494535
}
536+
if chatWindowViewModel.chat?.id != chat.id {
537+
chatWindowViewModel.chat = chat
538+
}
495539
} else {
496540
suggestionPanelViewModel.chat = nil
541+
chatWindowViewModel.chat = nil
497542
}
498-
543+
499544
if let provider = await dataSource?.promptToCodeForFile(at: fileURL) {
500545
suggestionPanelViewModel.content = .promptToCode(provider)
501546
} else if let suggestion = await dataSource?.suggestionForFile(at: fileURL) {

Core/Sources/SuggestionWidget/TabView.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ struct TabView: View {
99
case .chat:
1010
if panelViewModel.content != nil {
1111
Button(action: {
12-
panelViewModel.activeTab = .suggestion
12+
13+
panelViewModel.activeTab = .suggestion
1314
}, label: {
1415
Image(systemName: "lightbulb.fill")
1516
.frame(width: Style.widgetWidth, height: Style.widgetHeight)
@@ -23,7 +24,12 @@ struct TabView: View {
2324
case .suggestion:
2425
if panelViewModel.chat != nil {
2526
Button(action: {
26-
panelViewModel.activeTab = .chat
27+
if panelViewModel.chatPanelInASeparateWindow {
28+
panelViewModel.chatPanelInASeparateWindow = false
29+
panelViewModel.activeTab = .chat
30+
} else {
31+
panelViewModel.activeTab = .chat
32+
}
2733
}, label: {
2834
Image(systemName: "ellipsis.bubble.fill")
2935
.frame(width: Style.widgetWidth, height: Style.widgetHeight)

Core/Sources/SuggestionWidget/WidgetView.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ struct WidgetContextMenu: View {
107107
@AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally
108108
@AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList
109109
@AppStorage(\.customCommands) var customCommands
110+
@AppStorage(\.chatPanelInASeparateWindow) var chatPanelInASeparateWindow
110111
@ObservedObject var widgetViewModel: WidgetViewModel
111112
@State var projectPath: String?
112113
var isChatOpen: Bool
@@ -130,6 +131,15 @@ struct WidgetContextMenu: View {
130131
Divider()
131132

132133
Group { // Settings
134+
Button(action: {
135+
chatPanelInASeparateWindow.toggle()
136+
}) {
137+
Text("Detach Chat Panel")
138+
if chatPanelInASeparateWindow {
139+
Image(systemName: "checkmark")
140+
}
141+
}
142+
133143
Button(action: {
134144
useGlobalChat.toggle()
135145
}) {

ExtensionService/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
3434
setupQuitOnUserTerminated()
3535
xpcListener = setupXPCListener()
3636
Logger.service.info("XPC Service started.")
37-
NSApp.setActivationPolicy(.prohibited)
37+
NSApp.setActivationPolicy(.accessory)
3838
buildStatusBarMenu()
3939
}
4040

0 commit comments

Comments
 (0)