From 3db2ab420caecce7afdb6c0a1aa841dd0f917343 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 01:42:19 -0400 Subject: [PATCH 1/8] feat: pop out dock chat into its own window add a title bar control on the dock popover to open the same terminal in a floating window. the popped-out window keeps its own provider choice in memory, routes copy/refresh/provider menu to the right session, tears down on close, and survives style switches. also terminate detached sessions on quit, refresh detached chrome when the global theme changes, and re-apply terminal colors after rebuilds. character content view is non-opaque so alpha video hit testing stays consistent. --- LilAgents/CharacterContentView.swift | 2 + LilAgents/LilAgentsApp.swift | 12 +- LilAgents/TerminalView.swift | 17 + LilAgents/WalkerCharacter.swift | 505 +++++++++++++++++++++++---- 4 files changed, 475 insertions(+), 61 deletions(-) diff --git a/LilAgents/CharacterContentView.swift b/LilAgents/CharacterContentView.swift index 01b78f5..210a055 100644 --- a/LilAgents/CharacterContentView.swift +++ b/LilAgents/CharacterContentView.swift @@ -8,6 +8,8 @@ class KeyableWindow: NSWindow { class CharacterContentView: NSView { weak var character: WalkerCharacter? + override var isOpaque: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { let localPoint = convert(point, from: superview) guard bounds.contains(localPoint) else { return nil } diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index f0415da..88c83af 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -24,7 +24,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { - controller?.characters.forEach { $0.session?.terminate() } + controller?.characters.forEach { + $0.session?.terminate() + $0.detachedSession?.terminate() + } } // MARK: - Menu Bar @@ -144,6 +147,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } controller?.characters.forEach { char in + if char.detachedChatWindow != nil { + char.refreshDetachedChromeTheme() + char.detachedTerminalView?.reapplyAppearanceFromTheme() + return + } let wasOpen = char.isIdleForPopover if wasOpen { char.popoverWindow?.orderOut(nil) } char.popoverWindow = nil @@ -151,6 +159,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { char.thinkingBubbleWindow = nil if wasOpen { char.createPopoverWindow() + char.rewirePopoverSessionIfNeeded() if let session = char.session, !session.history.isEmpty { char.terminalView?.replayHistory(session.history) } @@ -173,6 +182,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { controller?.characters.forEach { char in if char.provider == newProvider { return } char.provider = newProvider + char.discardDetachedChatSilently() char.session?.terminate() char.session = nil char.popoverWindow?.orderOut(nil) diff --git a/LilAgents/TerminalView.swift b/LilAgents/TerminalView.swift index 6e0de91..98f0c3f 100644 --- a/LilAgents/TerminalView.swift +++ b/LilAgents/TerminalView.swift @@ -157,6 +157,23 @@ class TerminalView: NSView { addSubview(inputField) } + /// Re-apply colors and fonts from the current `theme` (e.g. after global style switch while this view is kept open). + func reapplyAppearanceFromTheme() { + let t = theme + textView.textColor = t.textPrimary + textView.font = t.font + textView.linkTextAttributes = [ + .foregroundColor: t.accentColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + if let cell = inputField.cell as? PaddedTextFieldCell { + cell.font = t.font + cell.textColor = t.textPrimary + } + updatePlaceholder() + needsDisplay = true + } + func resetState() { isStreaming = false currentAssistantText = "" diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index fa30824..fa065cd 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -20,6 +20,11 @@ enum CharacterSize: String, CaseIterable { } class WalkerCharacter { + private enum ChatChromeHost { + case dockPopover + case detachedWindow + } + let videoName: String let name: String var provider: AgentProvider { @@ -84,18 +89,29 @@ class WalkerCharacter { var isIdleForPopover = false var popoverWindow: NSWindow? var terminalView: TerminalView? + var detachedChatWindow: NSWindow? + var detachedTerminalView: TerminalView? + var detachedSession: (any AgentSession)? + private var detachedProvider: AgentProvider? var session: (any AgentSession)? var clickOutsideMonitor: Any? var escapeKeyMonitor: Any? var currentStreamingText = "" weak var controller: LilAgentsController? var themeOverride: PopoverTheme? - var isAgentBusy: Bool { session?.isBusy ?? false } + var isAgentBusy: Bool { (session?.isBusy ?? false) || (detachedSession?.isBusy ?? false) } var thinkingBubbleWindow: NSWindow? private(set) var isManuallyVisible = true private var environmentHiddenAt: CFTimeInterval? private var wasPopoverVisibleBeforeEnvironmentHide = false + private var wasDetachedVisibleBeforeEnvironmentHide = false private var wasBubbleVisibleBeforeEnvironmentHide = false + private var detachedWindowCloseObserver: NSObjectProtocol? + private weak var providerMenuHostWindow: NSWindow? + + private static let detachedTitleLeadingInset: CGFloat = 90 + private static let detachedProviderArrowButtonTag = 901 + private static let detachedProviderClickAreaTag = 902 init(videoName: String, name: String) { self.videoName = videoName @@ -172,6 +188,12 @@ class WalkerCharacter { window.orderFrontRegardless() } + deinit { + if let o = detachedWindowCloseObserver { + NotificationCenter.default.removeObserver(o) + } + } + // MARK: - Visibility func setManuallyVisible(_ visible: Bool) { @@ -184,6 +206,7 @@ class WalkerCharacter { queuePlayer.pause() window.orderOut(nil) popoverWindow?.orderOut(nil) + detachedChatWindow?.orderOut(nil) thinkingBubbleWindow?.orderOut(nil) } } @@ -193,11 +216,13 @@ class WalkerCharacter { environmentHiddenAt = CACurrentMediaTime() wasPopoverVisibleBeforeEnvironmentHide = popoverWindow?.isVisible ?? false + wasDetachedVisibleBeforeEnvironmentHide = detachedChatWindow?.isVisible ?? false wasBubbleVisibleBeforeEnvironmentHide = thinkingBubbleWindow?.isVisible ?? false queuePlayer.pause() window.orderOut(nil) popoverWindow?.orderOut(nil) + detachedChatWindow?.orderOut(nil) thinkingBubbleWindow?.orderOut(nil) } @@ -227,6 +252,14 @@ class WalkerCharacter { } } + if wasDetachedVisibleBeforeEnvironmentHide, let detached = detachedChatWindow { + detached.orderFrontRegardless() + detached.makeKey() + if let field = detachedTerminalView?.inputField { + detached.makeFirstResponder(field) + } + } + if wasBubbleVisibleBeforeEnvironmentHide { updateThinkingBubble() } @@ -320,15 +353,19 @@ class WalkerCharacter { showingCompletion = false hideBubble() + if popoverWindow == nil { + createPopoverWindow() + } + if session == nil { let newSession = provider.createSession() session = newSession - wireSession(newSession) + if let term = terminalView { + wireSession(newSession, terminal: term) + } newSession.start() - } - - if popoverWindow == nil { - createPopoverWindow() + } else if let s = session, let term = terminalView { + wireSession(s, terminal: term) } if let terminal = terminalView, let session = session, !session.history.isEmpty { @@ -462,6 +499,16 @@ class WalkerCharacter { clickArea.action = #selector(showProviderMenu(_:)) titleBar.addSubview(clickArea) + let popOutBtn = NSButton(frame: NSRect(x: popoverWidth - 68, y: 5, width: 16, height: 16)) + popOutBtn.image = NSImage(systemSymbolName: "arrow.up.right.square", accessibilityDescription: "Pop out chat") + popOutBtn.imageScaling = .scaleProportionallyDown + popOutBtn.bezelStyle = .inline + popOutBtn.isBordered = false + popOutBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) + popOutBtn.target = self + popOutBtn.action = #selector(popOutChatToDetachedWindow) + titleBar.addSubview(popOutBtn) + let refreshBtn = NSButton(frame: NSRect(x: popoverWidth - 48, y: 5, width: 16, height: 16)) refreshBtn.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") refreshBtn.imageScaling = .scaleProportionallyDown @@ -469,7 +516,7 @@ class WalkerCharacter { refreshBtn.isBordered = false refreshBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) refreshBtn.target = self - refreshBtn.action = #selector(refreshSessionFromButton) + refreshBtn.action = #selector(refreshSessionFromButton(_:)) titleBar.addSubview(refreshBtn) let copyBtn = NSButton(frame: NSRect(x: popoverWidth - 28, y: 5, width: 16, height: 16)) @@ -479,7 +526,7 @@ class WalkerCharacter { copyBtn.isBordered = false copyBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) copyBtn.target = self - copyBtn.action = #selector(copyLastResponseFromButton) + copyBtn.action = #selector(copyLastResponseFromButton(_:)) titleBar.addSubview(copyBtn) let sep = NSView(frame: NSRect(x: 0, y: popoverHeight - 29, width: popoverWidth, height: 1)) @@ -496,7 +543,7 @@ class WalkerCharacter { self?.session?.send(message: message) } terminal.onClearRequested = { [weak self] in - self?.resetSession() + self?.resetSession(for: .dockPopover) } container.addSubview(terminal) @@ -505,102 +552,440 @@ class WalkerCharacter { terminalView = terminal } - func resetSession() { - session?.terminate() - session = nil - currentStreamingText = "" - showingCompletion = false - currentPhrase = "" - completionBubbleExpiry = 0 - hideBubble() - terminalView?.resetState() - terminalView?.showSessionMessage() - let newSession = provider.createSession() - session = newSession - wireSession(newSession) - newSession.start() + /// After `terminalView` is replaced (e.g. style switch), rebind session callbacks to the new view. + func rewirePopoverSessionIfNeeded() { + guard let s = session, let term = terminalView else { return } + wireSession(s, terminal: term) } - private func wireSession(_ session: any AgentSession) { - session.onText = { [weak self] text in + /// Close popped-out chat without going through `willClose` teardown (e.g. global provider switch). + func discardDetachedChatSilently() { + if let o = detachedWindowCloseObserver { + NotificationCenter.default.removeObserver(o) + detachedWindowCloseObserver = nil + } + detachedSession?.terminate() + detachedSession = nil + detachedTerminalView = nil + detachedProvider = nil + detachedChatWindow?.close() + detachedChatWindow = nil + } + + private func resetSession(for host: ChatChromeHost) { + switch host { + case .detachedWindow: + guard detachedChatWindow != nil else { return } + detachedSession?.terminate() + currentStreamingText = "" + showingCompletion = false + currentPhrase = "" + completionBubbleExpiry = 0 + hideBubble() + detachedTerminalView?.resetState() + detachedTerminalView?.showSessionMessage() + let p = detachedProvider ?? provider + let newSession = p.createSession() + detachedSession = newSession + if let term = detachedTerminalView { + term.provider = p + wireSession(newSession, terminal: term) + term.onSendMessage = { [weak self] message in + self?.detachedSession?.send(message: message) + } + term.onClearRequested = { [weak self] in + self?.resetSession(for: .detachedWindow) + } + } + newSession.start() + + case .dockPopover: + session?.terminate() + session = nil + currentStreamingText = "" + showingCompletion = false + currentPhrase = "" + completionBubbleExpiry = 0 + hideBubble() + terminalView?.resetState() + terminalView?.showSessionMessage() + let newSession = provider.createSession() + session = newSession + if let term = terminalView { + wireSession(newSession, terminal: term) + } + newSession.start() + } + } + + private func wireSession(_ session: any AgentSession, terminal: TerminalView) { + session.onText = { [weak self, weak terminal] text in self?.currentStreamingText += text - self?.terminalView?.appendStreamingText(text) + terminal?.appendStreamingText(text) } - session.onTurnComplete = { [weak self] in - self?.terminalView?.endStreaming() + session.onTurnComplete = { [weak self, weak terminal] in + terminal?.endStreaming() self?.playCompletionSound() self?.showCompletionBubble() } - session.onError = { [weak self] text in - self?.terminalView?.appendError(text) + session.onError = { [weak terminal] text in + terminal?.appendError(text) } - session.onToolUse = { [weak self] toolName, input in + session.onToolUse = { [weak self, weak terminal] toolName, input in guard let self = self else { return } let summary = self.formatToolInput(input) - self.terminalView?.appendToolUse(toolName: toolName, summary: summary) + terminal?.appendToolUse(toolName: toolName, summary: summary) } - session.onToolResult = { [weak self] summary, isError in - self?.terminalView?.appendToolResult(summary: summary, isError: isError) + session.onToolResult = { [weak terminal] summary, isError in + terminal?.appendToolResult(summary: summary, isError: isError) } - session.onProcessExit = { [weak self] in + session.onProcessExit = { [weak self, weak terminal] in guard let self = self else { return } - self.terminalView?.endStreaming() - self.terminalView?.appendError("\(self.provider.displayName) session ended.") + terminal?.endStreaming() + let pname = (terminal === self.detachedTerminalView) + ? (self.detachedProvider ?? self.provider).displayName + : self.provider.displayName + terminal?.appendError("\(pname) session ended.") } session.onSessionReady = { } } + @objc func popOutChatToDetachedWindow() { + guard !isOnboarding, detachedChatWindow == nil else { return } + guard let sess = session, let term = terminalView, popoverWindow != nil else { return } + + removeEventMonitors() + term.removeFromSuperview() + popoverWindow?.orderOut(nil) + popoverWindow = nil + + detachedSession = sess + session = nil + detachedTerminalView = term + terminalView = nil + detachedProvider = provider + + wireSession(sess, terminal: term) + term.onSendMessage = { [weak self] message in + self?.detachedSession?.send(message: message) + } + term.onClearRequested = { [weak self] in + self?.resetSession(for: .detachedWindow) + } + + createDetachedChatWindowHostingExistingTerminal() + + isIdleForPopover = false + + if showingCompletion { + completionBubbleExpiry = CACurrentMediaTime() + 3.0 + showBubble(text: currentPhrase, isCompletion: true) + } else if isAgentBusy { + currentPhrase = "" + lastPhraseUpdate = 0 + updateThinkingPhrase() + showBubble(text: currentPhrase, isCompletion: false) + } + + let delay = Double.random(in: 30.0...60.0) + pauseEndTime = CACurrentMediaTime() + delay + queuePlayer.pause() + queuePlayer.seek(to: .zero) + + detachedChatWindow?.center() + detachedChatWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let field = detachedTerminalView?.inputField { + detachedChatWindow?.makeFirstResponder(field) + } + } + + private func createDetachedChatWindowHostingExistingTerminal() { + guard let term = detachedTerminalView else { return } + let t = resolvedTheme + let winW: CGFloat = 760 + let winH: CGFloat = 520 + + let win = KeyableWindow( + contentRect: NSRect(x: 0, y: 0, width: winW, height: winH), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + win.titleVisibility = .hidden + win.titlebarAppearsTransparent = true + win.isMovableByWindowBackground = true + win.minSize = NSSize(width: 480, height: 320) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = true + win.level = .floating + win.collectionBehavior = [.moveToActiveSpace] + let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 + win.appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) + let detachedP = detachedProvider ?? provider + win.title = "\(name) — \(detachedP.displayName)" + term.provider = detachedP + + let container = NSView(frame: NSRect(x: 0, y: 0, width: winW, height: winH)) + container.wantsLayer = true + container.layer?.backgroundColor = t.popoverBg.cgColor + container.layer?.cornerRadius = t.popoverCornerRadius + container.layer?.masksToBounds = true + container.layer?.borderWidth = t.popoverBorderWidth + container.layer?.borderColor = t.popoverBorder.cgColor + container.autoresizingMask = [.width, .height] + + let titleBar = NSView(frame: NSRect(x: 0, y: winH - 28, width: winW, height: 28)) + titleBar.wantsLayer = true + titleBar.layer?.backgroundColor = t.titleBarBg.cgColor + titleBar.autoresizingMask = [.width, .maxYMargin] + container.addSubview(titleBar) + + let titleLabel = NSTextField(labelWithString: t.titleString(for: detachedP)) + titleLabel.font = t.titleFont + titleLabel.textColor = t.titleText + titleLabel.sizeToFit() + titleLabel.frame.origin = NSPoint(x: Self.detachedTitleLeadingInset, y: 6) + titleBar.addSubview(titleLabel) + + let arrowBtn = NSButton(frame: NSRect(x: titleLabel.frame.maxX + 2, y: 5, width: 16, height: 16)) + arrowBtn.image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: "Switch provider") + arrowBtn.imageScaling = .scaleProportionallyDown + arrowBtn.bezelStyle = .inline + arrowBtn.isBordered = false + arrowBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) + arrowBtn.target = self + arrowBtn.action = #selector(showProviderMenu(_:)) + arrowBtn.tag = Self.detachedProviderArrowButtonTag + titleBar.addSubview(arrowBtn) + + let clickW = max(arrowBtn.frame.maxX - Self.detachedTitleLeadingInset + 8, 48) + let clickArea = NSButton(frame: NSRect(x: Self.detachedTitleLeadingInset, y: 0, width: clickW, height: 28)) + clickArea.isTransparent = true + clickArea.target = self + clickArea.action = #selector(showProviderMenu(_:)) + clickArea.tag = Self.detachedProviderClickAreaTag + titleBar.addSubview(clickArea) + + let refreshBtn = NSButton(frame: NSRect(x: winW - 48, y: 5, width: 16, height: 16)) + refreshBtn.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") + refreshBtn.imageScaling = .scaleProportionallyDown + refreshBtn.bezelStyle = .inline + refreshBtn.isBordered = false + refreshBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) + refreshBtn.target = self + refreshBtn.action = #selector(refreshSessionFromButton(_:)) + refreshBtn.autoresizingMask = .minXMargin + titleBar.addSubview(refreshBtn) + + let copyBtn = NSButton(frame: NSRect(x: winW - 28, y: 5, width: 16, height: 16)) + copyBtn.image = NSImage(systemSymbolName: "square.on.square", accessibilityDescription: "Copy") + copyBtn.imageScaling = .scaleProportionallyDown + copyBtn.bezelStyle = .inline + copyBtn.isBordered = false + copyBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) + copyBtn.autoresizingMask = .minXMargin + copyBtn.target = self + copyBtn.action = #selector(copyLastResponseFromButton(_:)) + titleBar.addSubview(copyBtn) + + let sep = NSView(frame: NSRect(x: 0, y: winH - 29, width: winW, height: 1)) + sep.wantsLayer = true + sep.layer?.backgroundColor = t.separatorColor.cgColor + sep.autoresizingMask = [.width, .maxYMargin] + container.addSubview(sep) + + term.frame = NSRect(x: 0, y: 0, width: winW, height: winH - 29) + term.autoresizingMask = [.width, .height] + container.addSubview(term) + + win.contentView = container + + let closingDetachedWindow = win + detachedWindowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: closingDetachedWindow, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard self.detachedChatWindow === closingDetachedWindow else { return } + self.teardownDetachedChatWindow() + } + } + + detachedChatWindow = win + } + + private func teardownDetachedChatWindow() { + if let o = detachedWindowCloseObserver { + NotificationCenter.default.removeObserver(o) + detachedWindowCloseObserver = nil + } + detachedSession?.terminate() + detachedSession = nil + detachedTerminalView = nil + detachedProvider = nil + detachedChatWindow = nil + } + + func refreshDetachedChromeTheme() { + guard let container = detachedChatWindow?.contentView else { return } + let t = resolvedTheme + container.layer?.backgroundColor = t.popoverBg.cgColor + container.layer?.borderColor = t.popoverBorder.cgColor + for view in container.subviews { + if abs(view.frame.height - 1) < 0.5 { + view.layer?.backgroundColor = t.separatorColor.cgColor + } + } + if let container = detachedChatWindow?.contentView, + let titleBar = container.subviews.first(where: { abs($0.frame.height - 28) < 0.5 && abs($0.frame.maxY - container.bounds.height) < 2 }) { + titleBar.layer?.backgroundColor = t.titleBarBg.cgColor + for sub in titleBar.subviews { + if let tf = sub as? NSTextField { + tf.textColor = t.titleText + tf.font = t.titleFont + } + if let btn = sub as? NSButton, btn.image != nil { + btn.contentTintColor = t.titleText.withAlphaComponent(0.75) + } + } + } + let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 + detachedChatWindow?.appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) + updateDetachedTitleBarProviderLabels() + } + @objc func showProviderMenu(_ sender: Any) { + guard let view = sender as? NSView, let hostWindow = view.window else { return } + guard let titleBar = view.superview, abs(titleBar.frame.height - 28) < 2 else { return } + + providerMenuHostWindow = hostWindow let menu = NSMenu() let menuFont = NSFont.systemFont(ofSize: 12, weight: .regular) + let selected: AgentProvider = (hostWindow === detachedChatWindow) ? (detachedProvider ?? provider) : provider for p in AgentProvider.allCases { let item = NSMenuItem(title: p.displayName, action: #selector(providerMenuItemSelected(_:)), keyEquivalent: "") item.target = self item.attributedTitle = NSAttributedString(string: p.displayName, attributes: [.font: menuFont]) item.representedObject = p.rawValue - if p == provider { - item.state = .on - } + item.state = p == selected ? .on : .off if !p.isAvailable { item.isEnabled = false } menu.addItem(item) } - // Show menu below the title bar area - if let titleBar = popoverWindow?.contentView?.subviews.first(where: { $0.frame.origin.y > 0 && $0.frame.height == 28 }) { - menu.popUp(positioning: nil, at: NSPoint(x: 10, y: 0), in: titleBar) - } + let menuX: CGFloat = (hostWindow === detachedChatWindow) ? view.frame.minX : 10 + menu.popUp(positioning: nil, at: NSPoint(x: menuX, y: 0), in: titleBar) } @objc func providerMenuItemSelected(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, - let newProvider = AgentProvider(rawValue: raw), - newProvider != provider else { return } - provider = newProvider - // Terminate existing session and rebuild popover for new provider - session?.terminate() - session = nil - popoverWindow?.orderOut(nil) - popoverWindow = nil - terminalView = nil - thinkingBubbleWindow?.orderOut(nil) - thinkingBubbleWindow = nil - openPopover() + let newProvider = AgentProvider(rawValue: raw) else { return } + + let host = providerMenuHostWindow + providerMenuHostWindow = nil + + if host === detachedChatWindow { + let current = detachedProvider ?? provider + guard newProvider != current else { return } + detachedProvider = newProvider + restartDetachedSessionForCurrentDetachedProvider() + return + } + + if host === popoverWindow { + guard newProvider != provider else { return } + provider = newProvider + session?.terminate() + session = nil + popoverWindow?.orderOut(nil) + popoverWindow = nil + terminalView = nil + thinkingBubbleWindow?.orderOut(nil) + thinkingBubbleWindow = nil + openPopover() + return + } + } + + private func restartDetachedSessionForCurrentDetachedProvider() { + guard detachedChatWindow != nil, let term = detachedTerminalView else { return } + let p = detachedProvider ?? provider + detachedSession?.terminate() + detachedSession = nil + currentStreamingText = "" + term.provider = p + term.resetState() + term.showSessionMessage() + let newSession = p.createSession() + detachedSession = newSession + wireSession(newSession, terminal: term) + term.onSendMessage = { [weak self] message in + self?.detachedSession?.send(message: message) + } + term.onClearRequested = { [weak self] in + self?.resetSession(for: .detachedWindow) + } + newSession.start() + updateDetachedTitleBarProviderLabels() + } + + private func updateDetachedTitleBarProviderLabels() { + guard let cv = detachedChatWindow?.contentView else { return } + let t = resolvedTheme + let p = detachedProvider ?? provider + detachedChatWindow?.title = "\(name) — \(p.displayName)" + guard let titleBar = cv.subviews.first(where: { abs($0.frame.height - 28) < 0.5 && abs($0.frame.maxY - cv.bounds.height) < 2 }) else { return } + + var titleField: NSTextField? + var providerArrow: NSButton? + for sub in titleBar.subviews { + if titleField == nil, let tf = sub as? NSTextField { titleField = tf } + if providerArrow == nil, let b = sub as? NSButton, b.tag == Self.detachedProviderArrowButtonTag { providerArrow = b } + } + guard let tf = titleField else { return } + tf.stringValue = t.titleString(for: p) + tf.sizeToFit() + tf.frame.origin = NSPoint(x: Self.detachedTitleLeadingInset, y: 6) + if let arrow = providerArrow { + var af = arrow.frame + af.origin.x = tf.frame.maxX + 2 + arrow.frame = af + } + if let click = titleBar.subviews.first(where: { ($0 as? NSButton)?.tag == Self.detachedProviderClickAreaTag }) as? NSButton { + let endX = (providerArrow?.frame.maxX ?? tf.frame.maxX) + 4 + let clickW = max(endX - Self.detachedTitleLeadingInset, 48) + click.frame = NSRect(x: Self.detachedTitleLeadingInset, y: 0, width: clickW, height: 28) + } } - @objc func copyLastResponseFromButton() { - terminalView?.handleSlashCommandPublic("/copy") + @objc func copyLastResponseFromButton(_ sender: Any?) { + let term: TerminalView? + if let view = sender as? NSView, view.window === detachedChatWindow { + term = detachedTerminalView + } else { + term = terminalView + } + term?.handleSlashCommandPublic("/copy") } - @objc func refreshSessionFromButton() { + @objc func refreshSessionFromButton(_ sender: Any?) { guard !isOnboarding else { return } - resetSession() + if let view = sender as? NSView, view.window === detachedChatWindow { + resetSession(for: .detachedWindow) + } else { + resetSession(for: .dockPopover) + } } private func formatToolInput(_ input: [String: Any]) -> String { From 7dfa8abb1c65653337fbcd419f8cf83acee870f2 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 02:00:30 -0400 Subject: [PATCH 2/8] fix: avoid hang when closing popped-out chat windows tear down after NSWindowDidClose instead of willClose, and defer session terminate to the next main run loop so closing a second pop-out does not deadlock appkit. --- LilAgents/WalkerCharacter.swift | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index fa065cd..e4361c3 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -558,18 +558,22 @@ class WalkerCharacter { wireSession(s, terminal: term) } - /// Close popped-out chat without going through `willClose` teardown (e.g. global provider switch). + /// Close popped-out chat without going through `didClose` teardown (e.g. global provider switch). func discardDetachedChatSilently() { if let o = detachedWindowCloseObserver { NotificationCenter.default.removeObserver(o) detachedWindowCloseObserver = nil } - detachedSession?.terminate() + let sess = detachedSession + let win = detachedChatWindow detachedSession = nil detachedTerminalView = nil detachedProvider = nil - detachedChatWindow?.close() detachedChatWindow = nil + win?.close() + DispatchQueue.main.async { + sess?.terminate() + } } private func resetSession(for host: ChatChromeHost) { @@ -810,31 +814,32 @@ class WalkerCharacter { let closingDetachedWindow = win detachedWindowCloseObserver = NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, + forName: Notification.Name("NSWindowDidClose"), object: closingDetachedWindow, queue: .main - ) { [weak self] _ in - guard let self = self else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - guard self.detachedChatWindow === closingDetachedWindow else { return } - self.teardownDetachedChatWindow() - } + ) { [weak self] note in + guard let self = self, + let closed = note.object as? NSWindow, + closed === self.detachedChatWindow else { return } + self.completeDetachedChatTeardownAfterWindowClosed() } detachedChatWindow = win } - private func teardownDetachedChatWindow() { + private func completeDetachedChatTeardownAfterWindowClosed() { if let o = detachedWindowCloseObserver { NotificationCenter.default.removeObserver(o) detachedWindowCloseObserver = nil } - detachedSession?.terminate() + let sess = detachedSession detachedSession = nil detachedTerminalView = nil detachedProvider = nil detachedChatWindow = nil + DispatchQueue.main.async { + sess?.terminate() + } } func refreshDetachedChromeTheme() { From ed80c75d6c98f87466b2a3b4a630a087bad1a396 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 02:04:58 -0400 Subject: [PATCH 3/8] fix pop-out action: sender selector and window validation --- LilAgents/WalkerCharacter.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index e4361c3..c611413 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -506,7 +506,7 @@ class WalkerCharacter { popOutBtn.isBordered = false popOutBtn.contentTintColor = t.titleText.withAlphaComponent(0.75) popOutBtn.target = self - popOutBtn.action = #selector(popOutChatToDetachedWindow) + popOutBtn.action = #selector(popOutChatToDetachedWindow(_:)) titleBar.addSubview(popOutBtn) let refreshBtn = NSButton(frame: NSRect(x: popoverWidth - 48, y: 5, width: 16, height: 16)) @@ -660,9 +660,18 @@ class WalkerCharacter { session.onSessionReady = { } } - @objc func popOutChatToDetachedWindow() { + @objc func popOutChatToDetachedWindow(_ sender: Any?) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.popOutChatToDetachedWindow(sender) + } + return + } + guard let pw = popoverWindow, + let senderView = sender as? NSView, + senderView.window === pw else { return } guard !isOnboarding, detachedChatWindow == nil else { return } - guard let sess = session, let term = terminalView, popoverWindow != nil else { return } + guard let sess = session, let term = terminalView else { return } removeEventMonitors() term.removeFromSuperview() From 11e8d62f54e09c10cf5ea422be90fab74026abdd Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 02:08:02 -0400 Subject: [PATCH 4/8] keep dock popover above character window and front on focus --- LilAgents/LilAgentsController.swift | 3 +++ LilAgents/WalkerCharacter.swift | 37 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index ec6fce9..cfdc1ed 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -246,6 +246,9 @@ class LilAgentsController { for (i, char) in sorted.enumerated() { char.window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + i) } + for char in activeChars where char.isIdleForPopover { + char.ensurePopoverAboveCharacterWindow() + } } deinit { diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index c611413..73fa18c 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -107,6 +107,7 @@ class WalkerCharacter { private var wasDetachedVisibleBeforeEnvironmentHide = false private var wasBubbleVisibleBeforeEnvironmentHide = false private var detachedWindowCloseObserver: NSObjectProtocol? + private var popoverBecameKeyObserver: NSObjectProtocol? private weak var providerMenuHostWindow: NSWindow? private static let detachedTitleLeadingInset: CGFloat = 90 @@ -192,6 +193,7 @@ class WalkerCharacter { if let o = detachedWindowCloseObserver { NotificationCenter.default.removeObserver(o) } + removePopoverBecameKeyObserver() } // MARK: - Visibility @@ -245,6 +247,7 @@ class WalkerCharacter { if isIdleForPopover && wasPopoverVisibleBeforeEnvironmentHide { updatePopoverPosition() + ensurePopoverAboveCharacterWindow() popoverWindow?.orderFrontRegardless() popoverWindow?.makeKey() if let terminal = terminalView { @@ -309,6 +312,7 @@ class WalkerCharacter { terminalView?.endStreaming() updatePopoverPosition() + ensurePopoverAboveCharacterWindow() popoverWindow?.orderFrontRegardless() // Set up click-outside to dismiss and complete onboarding @@ -325,6 +329,7 @@ class WalkerCharacter { if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor); clickOutsideMonitor = nil } if let monitor = escapeKeyMonitor { NSEvent.removeMonitor(monitor); escapeKeyMonitor = nil } popoverWindow?.orderOut(nil) + removePopoverBecameKeyObserver() popoverWindow = nil terminalView = nil isIdleForPopover = false @@ -373,6 +378,7 @@ class WalkerCharacter { } updatePopoverPosition() + ensurePopoverAboveCharacterWindow() popoverWindow?.orderFrontRegardless() popoverWindow?.makeKey() @@ -443,6 +449,7 @@ class WalkerCharacter { } func createPopoverWindow() { + removePopoverBecameKeyObserver() let t = resolvedTheme let popoverWidth: CGFloat = 420 let popoverHeight: CGFloat = 310 @@ -456,6 +463,7 @@ class WalkerCharacter { win.isOpaque = false win.backgroundColor = .clear win.hasShadow = true + // Level is synced to sit just above this character's window (see `ensurePopoverAboveCharacterWindow`). win.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 10) win.collectionBehavior = [.moveToActiveSpace, .stationary] let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 @@ -550,6 +558,33 @@ class WalkerCharacter { win.contentView = container popoverWindow = win terminalView = terminal + + popoverBecameKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: win, + queue: .main + ) { [weak self] _ in + guard let self = self, self.popoverWindow === win else { return } + self.ensurePopoverAboveCharacterWindow() + win.orderFrontRegardless() + } + } + + private func removePopoverBecameKeyObserver() { + if let o = popoverBecameKeyObserver { + NotificationCenter.default.removeObserver(o) + popoverBecameKeyObserver = nil + } + } + + /// Keeps the dock popover above this character's window while dock z-ordering updates each frame. + func ensurePopoverAboveCharacterWindow() { + guard let popover = popoverWindow, popover.isVisible else { return } + let anchor = window.level + let target = NSWindow.Level(rawValue: anchor.rawValue + 1) + if popover.level != target { + popover.level = target + } } /// After `terminalView` is replaced (e.g. style switch), rebind session callbacks to the new view. @@ -676,6 +711,7 @@ class WalkerCharacter { removeEventMonitors() term.removeFromSuperview() popoverWindow?.orderOut(nil) + removePopoverBecameKeyObserver() popoverWindow = nil detachedSession = sess @@ -923,6 +959,7 @@ class WalkerCharacter { session?.terminate() session = nil popoverWindow?.orderOut(nil) + removePopoverBecameKeyObserver() popoverWindow = nil terminalView = nil thinkingBubbleWindow?.orderOut(nil) From a8a2d2c1d09eb469e3fcfdb239b89caa6ff79a28 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 02:11:43 -0400 Subject: [PATCH 5/8] fix pop-out: click character focuses detached window, theme switch updates both --- LilAgents/LilAgentsApp.swift | 2 +- LilAgents/WalkerCharacter.swift | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index 88c83af..0435f91 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -150,7 +150,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { if char.detachedChatWindow != nil { char.refreshDetachedChromeTheme() char.detachedTerminalView?.reapplyAppearanceFromTheme() - return } let wasOpen = char.isIdleForPopover if wasOpen { char.popoverWindow?.orderOut(nil) } @@ -164,6 +163,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { char.terminalView?.replayHistory(session.history) } char.updatePopoverPosition() + char.ensurePopoverAboveCharacterWindow() char.popoverWindow?.orderFrontRegardless() char.popoverWindow?.makeKey() if let terminal = char.terminalView { diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index 73fa18c..d6fbca4 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -275,6 +275,14 @@ class WalkerCharacter { openOnboardingPopover() return } + if let detached = detachedChatWindow { + detached.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let field = detachedTerminalView?.inputField { + detached.makeFirstResponder(field) + } + return + } if isIdleForPopover { closePopover() } else { From 1258308c90a79ba144533c44244f35cc5d5d29e9 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 02:13:29 -0400 Subject: [PATCH 6/8] bring detached window to front when it becomes key --- LilAgents/WalkerCharacter.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index d6fbca4..ab20aaf 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -107,6 +107,7 @@ class WalkerCharacter { private var wasDetachedVisibleBeforeEnvironmentHide = false private var wasBubbleVisibleBeforeEnvironmentHide = false private var detachedWindowCloseObserver: NSObjectProtocol? + private var detachedBecameKeyObserver: NSObjectProtocol? private var popoverBecameKeyObserver: NSObjectProtocol? private weak var providerMenuHostWindow: NSWindow? @@ -193,6 +194,7 @@ class WalkerCharacter { if let o = detachedWindowCloseObserver { NotificationCenter.default.removeObserver(o) } + removeDetachedBecameKeyObserver() removePopoverBecameKeyObserver() } @@ -607,6 +609,7 @@ class WalkerCharacter { NotificationCenter.default.removeObserver(o) detachedWindowCloseObserver = nil } + removeDetachedBecameKeyObserver() let sess = detachedSession let win = detachedChatWindow detachedSession = nil @@ -877,14 +880,31 @@ class WalkerCharacter { self.completeDetachedChatTeardownAfterWindowClosed() } + detachedBecameKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: win, + queue: .main + ) { [weak self, weak win] _ in + guard let self = self, let w = win, self.detachedChatWindow === w else { return } + w.orderFrontRegardless() + } + detachedChatWindow = win } + private func removeDetachedBecameKeyObserver() { + if let o = detachedBecameKeyObserver { + NotificationCenter.default.removeObserver(o) + detachedBecameKeyObserver = nil + } + } + private func completeDetachedChatTeardownAfterWindowClosed() { if let o = detachedWindowCloseObserver { NotificationCenter.default.removeObserver(o) detachedWindowCloseObserver = nil } + removeDetachedBecameKeyObserver() let sess = detachedSession detachedSession = nil detachedTerminalView = nil From 92d6468d6cf517d487d136f3fdab97b73da337d1 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 10:00:46 -0400 Subject: [PATCH 7/8] fix repeat pop-out: replace existing detached window and reset observers --- LilAgents/CharacterContentView.swift | 18 ++++++++++++----- LilAgents/WalkerCharacter.swift | 30 +++++++++++++++------------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/LilAgents/CharacterContentView.swift b/LilAgents/CharacterContentView.swift index 210a055..dff9254 100644 --- a/LilAgents/CharacterContentView.swift +++ b/LilAgents/CharacterContentView.swift @@ -5,6 +5,11 @@ class KeyableWindow: NSWindow { override var canBecomeMain: Bool { true } } +class NonKeyableWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + class CharacterContentView: NSView { weak var character: WalkerCharacter? @@ -27,6 +32,11 @@ class CharacterContentView: NSView { let captureRect = CGRect(x: screenPoint.x - 0.5, y: flippedY - 0.5, width: 1, height: 1) guard let windowID = window?.windowNumber, windowID > 0 else { return nil } + // Fallback hit rect for when pixel sampling fails or video is paused + let insetX = bounds.width * 0.2 + let insetY = bounds.height * 0.15 + let hitRect = bounds.insetBy(dx: insetX, dy: insetY) + if let image = CGWindowListCreateImage( captureRect, .optionIncludingWindow, @@ -45,14 +55,12 @@ class CharacterContentView: NSView { if pixel[3] > 30 { return self } - return nil + // Pixel was transparent — use fallback rect if in center area + return hitRect.contains(localPoint) ? self : nil } } - // Fallback: accept click if within center 60% of the view - let insetX = bounds.width * 0.2 - let insetY = bounds.height * 0.15 - let hitRect = bounds.insetBy(dx: insetX, dy: insetY) + // CGWindowListCreateImage failed — use fallback return hitRect.contains(localPoint) ? self : nil } diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index ab20aaf..1f08eb8 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -167,7 +167,7 @@ class WalkerCharacter { let y = dockTopY - bottomPadding + yOffset let contentRect = CGRect(x: 0, y: y, width: displayWidth, height: displayHeight) - window = NSWindow( + window = NonKeyableWindow( contentRect: contentRect, styleMask: .borderless, backing: .buffered, @@ -277,14 +277,6 @@ class WalkerCharacter { openOnboardingPopover() return } - if let detached = detachedChatWindow { - detached.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - if let field = detachedTerminalView?.inputField { - detached.makeFirstResponder(field) - } - return - } if isIdleForPopover { closePopover() } else { @@ -587,11 +579,12 @@ class WalkerCharacter { } } - /// Keeps the dock popover above this character's window while dock z-ordering updates each frame. + /// Keeps the dock popover above this character's window. func ensurePopoverAboveCharacterWindow() { guard let popover = popoverWindow, popover.isVisible else { return } - let anchor = window.level - let target = NSWindow.Level(rawValue: anchor.rawValue + 1) + // Use a fixed level high enough to be above all character windows, + // same as detached window so they can naturally order via clicks + let target = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 15) if popover.level != target { popover.level = target } @@ -716,7 +709,11 @@ class WalkerCharacter { guard let pw = popoverWindow, let senderView = sender as? NSView, senderView.window === pw else { return } - guard !isOnboarding, detachedChatWindow == nil else { return } + guard !isOnboarding else { return } + // Allow popping out again while a detached window exists: replace it with this popover's session. + if detachedChatWindow != nil { + discardDetachedChatSilently() + } guard let sess = session, let term = terminalView else { return } removeEventMonitors() @@ -768,6 +765,11 @@ class WalkerCharacter { private func createDetachedChatWindowHostingExistingTerminal() { guard let term = detachedTerminalView else { return } + if let o = detachedWindowCloseObserver { + NotificationCenter.default.removeObserver(o) + detachedWindowCloseObserver = nil + } + removeDetachedBecameKeyObserver() let t = resolvedTheme let winW: CGFloat = 760 let winH: CGFloat = 520 @@ -785,7 +787,7 @@ class WalkerCharacter { win.isOpaque = false win.backgroundColor = .clear win.hasShadow = true - win.level = .floating + win.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 15) win.collectionBehavior = [.moveToActiveSpace] let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 win.appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) From 985d62da5d14760863552a97e95c82713380c471 Mon Sep 17 00:00:00 2001 From: avnikapoor Date: Sat, 11 Apr 2026 10:08:53 -0400 Subject: [PATCH 8/8] support multiple detached chat windows per character --- LilAgents/LilAgentsApp.swift | 6 +- LilAgents/WalkerCharacter.swift | 377 ++++++++++++++++++-------------- 2 files changed, 217 insertions(+), 166 deletions(-) diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index 0435f91..e5880f3 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -26,7 +26,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { controller?.characters.forEach { $0.session?.terminate() - $0.detachedSession?.terminate() + $0.terminateAllDetachedSessions() } } @@ -147,9 +147,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } controller?.characters.forEach { char in - if char.detachedChatWindow != nil { + if char.hasDetachedChats { char.refreshDetachedChromeTheme() - char.detachedTerminalView?.reapplyAppearanceFromTheme() + char.reapplyAppearanceToAllDetachedTerminals() } let wasOpen = char.isIdleForPopover if wasOpen { char.popoverWindow?.orderOut(nil) } diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index 1f08eb8..64d04bd 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -22,7 +22,28 @@ enum CharacterSize: String, CaseIterable { class WalkerCharacter { private enum ChatChromeHost { case dockPopover - case detachedWindow + case detachedWindow(NSWindow) + } + + private final class DetachedChatPanel { + let window: NSWindow + let terminal: TerminalView + var session: any AgentSession + var providerOverride: AgentProvider? + var closeObserver: NSObjectProtocol? + var becameKeyObserver: NSObjectProtocol? + + init( + window: NSWindow, + terminal: TerminalView, + session: any AgentSession, + providerOverride: AgentProvider? + ) { + self.window = window + self.terminal = terminal + self.session = session + self.providerOverride = providerOverride + } } let videoName: String @@ -89,25 +110,25 @@ class WalkerCharacter { var isIdleForPopover = false var popoverWindow: NSWindow? var terminalView: TerminalView? - var detachedChatWindow: NSWindow? - var detachedTerminalView: TerminalView? - var detachedSession: (any AgentSession)? - private var detachedProvider: AgentProvider? + private var detachedPanels: [DetachedChatPanel] = [] var session: (any AgentSession)? var clickOutsideMonitor: Any? var escapeKeyMonitor: Any? var currentStreamingText = "" weak var controller: LilAgentsController? var themeOverride: PopoverTheme? - var isAgentBusy: Bool { (session?.isBusy ?? false) || (detachedSession?.isBusy ?? false) } + var isAgentBusy: Bool { + if session?.isBusy == true { return true } + return detachedPanels.contains { $0.session.isBusy } + } + + var hasDetachedChats: Bool { !detachedPanels.isEmpty } var thinkingBubbleWindow: NSWindow? private(set) var isManuallyVisible = true private var environmentHiddenAt: CFTimeInterval? private var wasPopoverVisibleBeforeEnvironmentHide = false private var wasDetachedVisibleBeforeEnvironmentHide = false private var wasBubbleVisibleBeforeEnvironmentHide = false - private var detachedWindowCloseObserver: NSObjectProtocol? - private var detachedBecameKeyObserver: NSObjectProtocol? private var popoverBecameKeyObserver: NSObjectProtocol? private weak var providerMenuHostWindow: NSWindow? @@ -191,10 +212,11 @@ class WalkerCharacter { } deinit { - if let o = detachedWindowCloseObserver { - NotificationCenter.default.removeObserver(o) + for panel in detachedPanels { + if let o = panel.closeObserver { NotificationCenter.default.removeObserver(o) } + if let o = panel.becameKeyObserver { NotificationCenter.default.removeObserver(o) } } - removeDetachedBecameKeyObserver() + detachedPanels.removeAll() removePopoverBecameKeyObserver() } @@ -210,7 +232,9 @@ class WalkerCharacter { queuePlayer.pause() window.orderOut(nil) popoverWindow?.orderOut(nil) - detachedChatWindow?.orderOut(nil) + for panel in detachedPanels { + panel.window.orderOut(nil) + } thinkingBubbleWindow?.orderOut(nil) } } @@ -220,13 +244,15 @@ class WalkerCharacter { environmentHiddenAt = CACurrentMediaTime() wasPopoverVisibleBeforeEnvironmentHide = popoverWindow?.isVisible ?? false - wasDetachedVisibleBeforeEnvironmentHide = detachedChatWindow?.isVisible ?? false + wasDetachedVisibleBeforeEnvironmentHide = detachedPanels.contains { $0.window.isVisible } wasBubbleVisibleBeforeEnvironmentHide = thinkingBubbleWindow?.isVisible ?? false queuePlayer.pause() window.orderOut(nil) popoverWindow?.orderOut(nil) - detachedChatWindow?.orderOut(nil) + for panel in detachedPanels { + panel.window.orderOut(nil) + } thinkingBubbleWindow?.orderOut(nil) } @@ -257,11 +283,13 @@ class WalkerCharacter { } } - if wasDetachedVisibleBeforeEnvironmentHide, let detached = detachedChatWindow { - detached.orderFrontRegardless() - detached.makeKey() - if let field = detachedTerminalView?.inputField { - detached.makeFirstResponder(field) + if wasDetachedVisibleBeforeEnvironmentHide { + for panel in detachedPanels { + panel.window.orderFrontRegardless() + } + if let front = detachedPanels.last { + front.window.makeKey() + front.window.makeFirstResponder(front.terminal.inputField) } } @@ -596,49 +624,96 @@ class WalkerCharacter { wireSession(s, terminal: term) } - /// Close popped-out chat without going through `didClose` teardown (e.g. global provider switch). - func discardDetachedChatSilently() { - if let o = detachedWindowCloseObserver { + private func detachedPanel(for window: NSWindow) -> DetachedChatPanel? { + detachedPanels.first { $0.window === window } + } + + private func bindDetachedPanelCallbacks(_ panel: DetachedChatPanel) { + let win = panel.window + let term = panel.terminal + wireSession(panel.session, terminal: term) + term.onSendMessage = { [weak self] message in + guard let self, let p = self.detachedPanel(for: win) else { return } + p.session.send(message: message) + } + term.onClearRequested = { [weak self] in + self?.resetSession(for: .detachedWindow(win)) + } + } + + private func handleDetachedWindowDidClose(_ panel: DetachedChatPanel) { + if let o = panel.closeObserver { NotificationCenter.default.removeObserver(o) - detachedWindowCloseObserver = nil - } - removeDetachedBecameKeyObserver() - let sess = detachedSession - let win = detachedChatWindow - detachedSession = nil - detachedTerminalView = nil - detachedProvider = nil - detachedChatWindow = nil - win?.close() + panel.closeObserver = nil + } + if let o = panel.becameKeyObserver { + NotificationCenter.default.removeObserver(o) + panel.becameKeyObserver = nil + } + let sess = panel.session + detachedPanels.removeAll { $0 === panel } DispatchQueue.main.async { - sess?.terminate() + sess.terminate() + } + } + + func terminateAllDetachedSessions() { + for panel in detachedPanels { + panel.session.terminate() + } + } + + func reapplyAppearanceToAllDetachedTerminals() { + for panel in detachedPanels { + panel.terminal.reapplyAppearanceFromTheme() + } + } + + /// Close popped-out chat without going through `didClose` teardown (e.g. global provider switch). + func discardDetachedChatSilently() { + let panels = detachedPanels + detachedPanels.removeAll() + for panel in panels { + if let o = panel.closeObserver { + NotificationCenter.default.removeObserver(o) + panel.closeObserver = nil + } + if let o = panel.becameKeyObserver { + NotificationCenter.default.removeObserver(o) + panel.becameKeyObserver = nil + } + let sess = panel.session + panel.window.close() + DispatchQueue.main.async { + sess.terminate() + } } } private func resetSession(for host: ChatChromeHost) { switch host { - case .detachedWindow: - guard detachedChatWindow != nil else { return } - detachedSession?.terminate() + case .detachedWindow(let win): + guard let panel = detachedPanel(for: win) else { return } + panel.session.terminate() currentStreamingText = "" showingCompletion = false currentPhrase = "" completionBubbleExpiry = 0 hideBubble() - detachedTerminalView?.resetState() - detachedTerminalView?.showSessionMessage() - let p = detachedProvider ?? provider + let term = panel.terminal + term.resetState() + term.showSessionMessage() + let p = panel.providerOverride ?? provider let newSession = p.createSession() - detachedSession = newSession - if let term = detachedTerminalView { - term.provider = p - wireSession(newSession, terminal: term) - term.onSendMessage = { [weak self] message in - self?.detachedSession?.send(message: message) - } - term.onClearRequested = { [weak self] in - self?.resetSession(for: .detachedWindow) - } + panel.session = newSession + term.provider = p + wireSession(newSession, terminal: term) + term.onSendMessage = { [weak self] message in + guard let self, let p = self.detachedPanel(for: win) else { return } + p.session.send(message: message) + } + term.onClearRequested = { [weak self] in + self?.resetSession(for: .detachedWindow(win)) } newSession.start() @@ -690,9 +765,13 @@ class WalkerCharacter { session.onProcessExit = { [weak self, weak terminal] in guard let self = self else { return } terminal?.endStreaming() - let pname = (terminal === self.detachedTerminalView) - ? (self.detachedProvider ?? self.provider).displayName - : self.provider.displayName + let pname: String + if let term = terminal, + let panel = self.detachedPanels.first(where: { $0.terminal === term }) { + pname = (panel.providerOverride ?? self.provider).displayName + } else { + pname = self.provider.displayName + } terminal?.appendError("\(pname) session ended.") } @@ -710,10 +789,6 @@ class WalkerCharacter { let senderView = sender as? NSView, senderView.window === pw else { return } guard !isOnboarding else { return } - // Allow popping out again while a detached window exists: replace it with this popover's session. - if detachedChatWindow != nil { - discardDetachedChatSilently() - } guard let sess = session, let term = terminalView else { return } removeEventMonitors() @@ -722,21 +797,11 @@ class WalkerCharacter { removePopoverBecameKeyObserver() popoverWindow = nil - detachedSession = sess session = nil - detachedTerminalView = term terminalView = nil - detachedProvider = provider - - wireSession(sess, terminal: term) - term.onSendMessage = { [weak self] message in - self?.detachedSession?.send(message: message) - } - term.onClearRequested = { [weak self] in - self?.resetSession(for: .detachedWindow) - } - createDetachedChatWindowHostingExistingTerminal() + let panel = createDetachedChatWindow(session: sess, terminal: term, providerOverride: provider) + bindDetachedPanelCallbacks(panel) isIdleForPopover = false @@ -755,21 +820,17 @@ class WalkerCharacter { queuePlayer.pause() queuePlayer.seek(to: .zero) - detachedChatWindow?.center() - detachedChatWindow?.makeKeyAndOrderFront(nil) + panel.window.center() + panel.window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - if let field = detachedTerminalView?.inputField { - detachedChatWindow?.makeFirstResponder(field) - } + panel.window.makeFirstResponder(panel.terminal.inputField) } - private func createDetachedChatWindowHostingExistingTerminal() { - guard let term = detachedTerminalView else { return } - if let o = detachedWindowCloseObserver { - NotificationCenter.default.removeObserver(o) - detachedWindowCloseObserver = nil - } - removeDetachedBecameKeyObserver() + private func createDetachedChatWindow( + session sess: any AgentSession, + terminal term: TerminalView, + providerOverride: AgentProvider? + ) -> DetachedChatPanel { let t = resolvedTheme let winW: CGFloat = 760 let winH: CGFloat = 520 @@ -791,7 +852,7 @@ class WalkerCharacter { win.collectionBehavior = [.moveToActiveSpace] let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 win.appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) - let detachedP = detachedProvider ?? provider + let detachedP = providerOverride ?? provider win.title = "\(name) — \(detachedP.displayName)" term.provider = detachedP @@ -870,79 +931,64 @@ class WalkerCharacter { win.contentView = container - let closingDetachedWindow = win - detachedWindowCloseObserver = NotificationCenter.default.addObserver( + let panel = DetachedChatPanel( + window: win, + terminal: term, + session: sess, + providerOverride: providerOverride + ) + + panel.closeObserver = NotificationCenter.default.addObserver( forName: Notification.Name("NSWindowDidClose"), - object: closingDetachedWindow, + object: win, queue: .main ) { [weak self] note in - guard let self = self, + guard let self, let closed = note.object as? NSWindow, - closed === self.detachedChatWindow else { return } - self.completeDetachedChatTeardownAfterWindowClosed() + let found = self.detachedPanels.first(where: { $0.window === closed }) else { return } + self.handleDetachedWindowDidClose(found) } - detachedBecameKeyObserver = NotificationCenter.default.addObserver( + panel.becameKeyObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: win, queue: .main - ) { [weak self, weak win] _ in - guard let self = self, let w = win, self.detachedChatWindow === w else { return } - w.orderFrontRegardless() + ) { [weak panel] _ in + panel?.window.orderFrontRegardless() } - detachedChatWindow = win - } - - private func removeDetachedBecameKeyObserver() { - if let o = detachedBecameKeyObserver { - NotificationCenter.default.removeObserver(o) - detachedBecameKeyObserver = nil - } - } - - private func completeDetachedChatTeardownAfterWindowClosed() { - if let o = detachedWindowCloseObserver { - NotificationCenter.default.removeObserver(o) - detachedWindowCloseObserver = nil - } - removeDetachedBecameKeyObserver() - let sess = detachedSession - detachedSession = nil - detachedTerminalView = nil - detachedProvider = nil - detachedChatWindow = nil - DispatchQueue.main.async { - sess?.terminate() - } + detachedPanels.append(panel) + return panel } func refreshDetachedChromeTheme() { - guard let container = detachedChatWindow?.contentView else { return } let t = resolvedTheme - container.layer?.backgroundColor = t.popoverBg.cgColor - container.layer?.borderColor = t.popoverBorder.cgColor - for view in container.subviews { - if abs(view.frame.height - 1) < 0.5 { - view.layer?.backgroundColor = t.separatorColor.cgColor - } - } - if let container = detachedChatWindow?.contentView, - let titleBar = container.subviews.first(where: { abs($0.frame.height - 28) < 0.5 && abs($0.frame.maxY - container.bounds.height) < 2 }) { - titleBar.layer?.backgroundColor = t.titleBarBg.cgColor - for sub in titleBar.subviews { - if let tf = sub as? NSTextField { - tf.textColor = t.titleText - tf.font = t.titleFont + let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 + let appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) + for panel in detachedPanels { + guard let container = panel.window.contentView else { continue } + container.layer?.backgroundColor = t.popoverBg.cgColor + container.layer?.borderColor = t.popoverBorder.cgColor + for view in container.subviews { + if abs(view.frame.height - 1) < 0.5 { + view.layer?.backgroundColor = t.separatorColor.cgColor } - if let btn = sub as? NSButton, btn.image != nil { - btn.contentTintColor = t.titleText.withAlphaComponent(0.75) + } + if let titleBar = container.subviews.first(where: { abs($0.frame.height - 28) < 0.5 && abs($0.frame.maxY - container.bounds.height) < 2 }) { + titleBar.layer?.backgroundColor = t.titleBarBg.cgColor + for sub in titleBar.subviews { + if let tf = sub as? NSTextField { + tf.textColor = t.titleText + tf.font = t.titleFont + } + if let btn = sub as? NSButton, btn.image != nil { + btn.contentTintColor = t.titleText.withAlphaComponent(0.75) + } } } + panel.window.appearance = appearance + updateDetachedTitleBarProviderLabels(for: panel.window) } - let brightness = t.popoverBg.redComponent * 0.299 + t.popoverBg.greenComponent * 0.587 + t.popoverBg.blueComponent * 0.114 - detachedChatWindow?.appearance = NSAppearance(named: brightness < 0.5 ? .darkAqua : .aqua) - updateDetachedTitleBarProviderLabels() } @objc func showProviderMenu(_ sender: Any) { @@ -952,7 +998,12 @@ class WalkerCharacter { providerMenuHostWindow = hostWindow let menu = NSMenu() let menuFont = NSFont.systemFont(ofSize: 12, weight: .regular) - let selected: AgentProvider = (hostWindow === detachedChatWindow) ? (detachedProvider ?? provider) : provider + let selected: AgentProvider + if let panel = detachedPanel(for: hostWindow) { + selected = panel.providerOverride ?? provider + } else { + selected = provider + } for p in AgentProvider.allCases { let item = NSMenuItem(title: p.displayName, action: #selector(providerMenuItemSelected(_:)), keyEquivalent: "") item.target = self @@ -964,7 +1015,7 @@ class WalkerCharacter { } menu.addItem(item) } - let menuX: CGFloat = (hostWindow === detachedChatWindow) ? view.frame.minX : 10 + let menuX: CGFloat = detachedPanel(for: hostWindow) != nil ? view.frame.minX : 10 menu.popUp(positioning: nil, at: NSPoint(x: menuX, y: 0), in: titleBar) } @@ -974,12 +1025,13 @@ class WalkerCharacter { let host = providerMenuHostWindow providerMenuHostWindow = nil + guard let host else { return } - if host === detachedChatWindow { - let current = detachedProvider ?? provider + if let panel = detachedPanels.first(where: { $0.window === host }) { + let current = panel.providerOverride ?? provider guard newProvider != current else { return } - detachedProvider = newProvider - restartDetachedSessionForCurrentDetachedProvider() + panel.providerOverride = newProvider + restartDetachedSession(for: host) return } @@ -999,33 +1051,28 @@ class WalkerCharacter { } } - private func restartDetachedSessionForCurrentDetachedProvider() { - guard detachedChatWindow != nil, let term = detachedTerminalView else { return } - let p = detachedProvider ?? provider - detachedSession?.terminate() - detachedSession = nil + private func restartDetachedSession(for hostWindow: NSWindow) { + guard let panel = detachedPanel(for: hostWindow) else { return } + let term = panel.terminal + let p = panel.providerOverride ?? provider + panel.session.terminate() currentStreamingText = "" term.provider = p term.resetState() term.showSessionMessage() let newSession = p.createSession() - detachedSession = newSession - wireSession(newSession, terminal: term) - term.onSendMessage = { [weak self] message in - self?.detachedSession?.send(message: message) - } - term.onClearRequested = { [weak self] in - self?.resetSession(for: .detachedWindow) - } + panel.session = newSession + bindDetachedPanelCallbacks(panel) newSession.start() - updateDetachedTitleBarProviderLabels() + updateDetachedTitleBarProviderLabels(for: hostWindow) } - private func updateDetachedTitleBarProviderLabels() { - guard let cv = detachedChatWindow?.contentView else { return } + private func updateDetachedTitleBarProviderLabels(for hostWindow: NSWindow) { + guard let panel = detachedPanel(for: hostWindow) else { return } + guard let cv = panel.window.contentView else { return } let t = resolvedTheme - let p = detachedProvider ?? provider - detachedChatWindow?.title = "\(name) — \(p.displayName)" + let p = panel.providerOverride ?? provider + panel.window.title = "\(name) — \(p.displayName)" guard let titleBar = cv.subviews.first(where: { abs($0.frame.height - 28) < 0.5 && abs($0.frame.maxY - cv.bounds.height) < 2 }) else { return } var titleField: NSTextField? @@ -1052,8 +1099,10 @@ class WalkerCharacter { @objc func copyLastResponseFromButton(_ sender: Any?) { let term: TerminalView? - if let view = sender as? NSView, view.window === detachedChatWindow { - term = detachedTerminalView + if let view = sender as? NSView, + let w = view.window, + let panel = detachedPanel(for: w) { + term = panel.terminal } else { term = terminalView } @@ -1062,8 +1111,10 @@ class WalkerCharacter { @objc func refreshSessionFromButton(_ sender: Any?) { guard !isOnboarding else { return } - if let view = sender as? NSView, view.window === detachedChatWindow { - resetSession(for: .detachedWindow) + if let view = sender as? NSView, + let w = view.window, + detachedPanel(for: w) != nil { + resetSession(for: .detachedWindow(w)) } else { resetSession(for: .dockPopover) }