diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 493350b..6468041 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -395,6 +395,33 @@ final class AppState: ObservableObject { #endif // Claude plan gauge (from statusline hook) + // MARK: Chat panel size (BroV) + // Detents: 0 = fits the messages (as before), 1 = ⅓ screen, 2 = ½ screen, 3 = full height. + @Published var chatDetent: Int = 0 + /// Live height while the user drags the handle; nil otherwise. + @Published var chatDragHeight: CGFloat? = nil + @Published var chatWidth: CGFloat = 720 { + didSet { UserDefaults.standard.set(Double(chatWidth), forKey: "chatWidth") } + } + static let chatWidthOptions: [CGFloat] = [640, 720, 820, 960] + + var screenHeight: CGFloat { + (IslandWindowController.notchScreen() ?? NSScreen.main)?.frame.height ?? 900 + } + + func chatDetentHeight(_ d: Int) -> CGFloat { + let auto = min(300, 240 + CGFloat(chatHistory.count) * 40) + switch d { + case 1: return max(320, screenHeight / 3) + case 2: return max(340, screenHeight / 2) + case 3: return screenHeight - 6 + default: return auto + } + } + + /// Current chat island height (drag, else detent). + var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) } + /// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor. @Published var codexWeekly: PlanWindow? = nil @Published var claudePlanUsage: PlanUsage? = nil { @@ -433,6 +460,7 @@ final class AppState: ObservableObject { !v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v } + if let v = ud.object(forKey: "chatWidth") as? Double, v >= 560 { chatWidth = CGFloat(v) } if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v } if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v } if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v } diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index e7f39c3..7f16936 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -163,7 +163,7 @@ struct BotCanvasView: View { progress: state.uploadProgress, nw: state.notchWidth, nh: state.notchHeight) let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) - ? min(300, 240 + CGFloat(state.chatHistory.count) * 40) + ? state.chatIslandHeight : islandH let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: actualH, diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index 5677d22..f47da83 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -32,11 +32,7 @@ struct IslandContainer: View { private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72) private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34) - private var chatPromptHeight: CGFloat { - let base: CGFloat = 240 - let perMsg: CGFloat = 40 - return min(300, base + CGFloat(state.chatHistory.count) * perMsg) - } + private var chatPromptHeight: CGFloat { state.chatIslandHeight } /// Pixels the content must be pushed down to clear the concave ear transparent area. /// = 0 in expanded mode (no ears), = earRadius in compact/notch mode. @@ -106,6 +102,13 @@ struct IslandContainer: View { CountdownBar(state: state, islandW: islandWidth) + // Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen). + if state.mode == .expanded && [.prompt, .overview, .empty].contains(state.view) { + ChatResizeHandle(state: state) + .position(x: islandWidth / 2, y: islandHeight - 7) + .transition(.opacity) + } + Group { if state.mode == .compact { CompactMiniGrid(state: state) @@ -151,6 +154,18 @@ struct IslandContainer: View { guard state.mode == .expanded, state.view == .prompt else { return } withAnimation(openSpring) { islandHeight = chatPromptHeight } } + .onChange(of: state.chatDragHeight) { _, h in + guard state.mode == .expanded, state.view == .prompt, let h else { return } + islandHeight = h // follow the pointer, no spring + } + .onChange(of: state.chatDetent) { _, _ in + guard state.mode == .expanded, state.view == .prompt else { return } + withAnimation(openSpring) { islandHeight = chatPromptHeight } + } + .onChange(of: state.chatWidth) { _, w in + guard state.mode == .expanded, state.view == .prompt else { return } + withAnimation(openSpring) { islandWidth = w } + } .onAppear { let (w, h) = islandSize(mode: state.mode, view: state.view, progress: state.uploadProgress, @@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH: let cy: CGFloat if let fixedY = layout.botY { cy = fixedY + } else if view == .prompt { + // Tall chat: keep the character near the top instead of mid-screen. + let headerBottom: CGFloat = 42 + let cardH: CGFloat = 84 + cy = min(171, headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2) } else { // Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42) let headerBottom: CGFloat = 42 @@ -376,6 +396,52 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH: } } +// MARK: - Chat resize handle + +/// Thin pill at the bottom of the island. Drag down: the chat follows the pointer and +/// snaps to the nearest detent (fits messages, ⅓, ½, full screen). From the home view +/// it opens the chat first. +struct ChatResizeHandle: View { + @ObservedObject var state: AppState + @State private var startHeight: CGFloat? = nil + @State private var isHovered = false + + var body: some View { + Capsule() + .fill(Color.white.opacity(isHovered || startHeight != nil ? 0.55 : 0.22)) + .frame(width: isHovered ? 54 : 40, height: 4) + .frame(width: 160, height: 14) + .contentShape(Rectangle()) + .onHover { h in + withAnimation(.easeOut(duration: 0.15)) { isHovered = h } + if h { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } + } + .gesture( + DragGesture(minimumDistance: 2, coordinateSpace: .global) + .onChanged { g in + if startHeight == nil { + if state.view != .prompt { + state.chatDetent = 0 + state.view = .prompt + } + startHeight = state.chatDetentHeight(state.chatDetent) + } + let h = (startHeight ?? 0) + g.translation.height + state.chatDragHeight = min(state.chatDetentHeight(3), max(state.chatDetentHeight(0), h)) + } + .onEnded { g in + let target = (startHeight ?? 0) + g.predictedEndTranslation.height + let best = (0...3).min { abs(state.chatDetentHeight($0) - target) < abs(state.chatDetentHeight($1) - target) } ?? 0 + state.chatDetent = best + state.chatDragHeight = nil + startHeight = nil + SoundEngine.shared.play("tick") + } + ) + .help("Потяни, чтобы развернуть чат") + } +} + // MARK: - Countdown bar struct CountdownBar: View { diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index c49e37f..ac62f6c 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -56,9 +56,11 @@ final class IslandWindowController: NSWindowController { let nW = geometry.width let nH = geometry.height - let panelW: CGFloat = 720 - let panelH: CGFloat = 320 + // Tall, transparent panel: the chat can grow down to the full screen height + // and widen; clicks outside the island still pass through (pollFrame). let sf = screen.frame + let panelW: CGFloat = 1100 + let panelH: CGFloat = sf.height let panel = IslandPanel( contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH, width: panelW, height: panelH), @@ -232,10 +234,12 @@ final class IslandWindowController: NSWindowController { // in the app window immediately below the menu bar. let hoverRect = !hasNotch && state.mode != .expanded ? islandRect : islandRect.insetBy(dx: -6, dy: -6) - let inIsland = hoverRect.contains(local) + // While the chat handle is dragged, count the pointer as inside (no collapse). + let inIsland = hoverRect.contains(local) || state.chatDragHeight != nil // Toggle click-through let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil + || state.chatDragHeight != nil if panel.ignoresMouseEvents == shouldAcceptMouse { panel.ignoresMouseEvents = !shouldAcceptMouse if shouldAcceptMouse, let cv = panel.contentView { @@ -1015,12 +1019,10 @@ final class IslandWindowController: NSWindowController { let panelW = window?.frame.width ?? 720 let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view, progress: s.uploadProgress, nw: notchW, nh: notchH) - // Chat view resizes dynamically — must match IslandContainer.chatPromptHeight + // Chat view resizes dynamically — AppState.chatIslandHeight let islandH: CGFloat if s.mode == .expanded && s.view == .prompt { - let base: CGFloat = 240 - let perMsg: CGFloat = 40 - islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg) + islandH = s.chatIslandHeight } else { islandH = fixedH } @@ -1083,9 +1085,7 @@ final class IslandPanel: NSPanel { progress: s.uploadProgress, nw: nw, nh: nh) let h: CGFloat if s.mode == .expanded && s.view == .prompt { - let base: CGFloat = 240 - let perMsg: CGFloat = 40 - h = min(300, base + CGFloat(s.chatHistory.count) * perMsg) + h = s.chatIslandHeight } else { h = fixedH } @@ -1140,6 +1140,7 @@ extension Notification.Name { // MARK: - islandSize (takes real notch dimensions) +@MainActor func islandSize(mode: IslandMode, view: IslandView, progress: Double = 0, nw: CGFloat = IslandConst.notchWidth, @@ -1149,6 +1150,8 @@ func islandSize(mode: IslandMode, view: IslandView, case .compact: return (nw + 160, nh) case .expanded: let layout = IslandConst.viewLayouts[view]! + // BroV: the chat has its own width (Settings → Чат). + if view == .prompt { return (AppState.shared.chatWidth, layout.height) } return (IslandConst.expandedWidth, layout.height) } } diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 81c8f05..b95c5e1 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -621,6 +621,12 @@ struct SettingsView: View { } } } + Picker("Ширина чата", selection: $state.chatWidth) { + ForEach(AppState.chatWidthOptions, id: \.self) { w in + Text(w == 640 ? "640 (как главный экран)" : "\(Int(w))").tag(w) + } + } + .pickerStyle(.segmented) Picker("Модель", selection: $state.claudeCodeModel) { ForEach(ClaudeCodeCLI.models, id: \.id) { m in Text(m.label).tag(m.id)