Chat: drag handle with detents (fit, 1/3, 1/2, full screen), chat width setting; tall transparent panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 22:19:27 +03:00
parent fbf90fd4fb
commit a4d5032959
5 changed files with 119 additions and 16 deletions
+28
View File
@@ -395,6 +395,33 @@ final class AppState: ObservableObject {
#endif #endif
// Claude plan gauge (from statusline hook) // 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. /// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor.
@Published var codexWeekly: PlanWindow? = nil @Published var codexWeekly: PlanWindow? = nil
@Published var claudePlanUsage: PlanUsage? = nil { @Published var claudePlanUsage: PlanUsage? = nil {
@@ -433,6 +460,7 @@ final class AppState: ObservableObject {
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } !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: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v } 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: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v } if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v } if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
+1 -1
View File
@@ -163,7 +163,7 @@ struct BotCanvasView: View {
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt)
? min(300, 240 + CGFloat(state.chatHistory.count) * 40) ? state.chatIslandHeight
: islandH : islandH
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
islandW: islandW, islandH: actualH, islandW: islandW, islandH: actualH,
+71 -5
View File
@@ -32,11 +32,7 @@ struct IslandContainer: View {
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72) 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 let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
private var chatPromptHeight: CGFloat { private var chatPromptHeight: CGFloat { state.chatIslandHeight }
let base: CGFloat = 240
let perMsg: CGFloat = 40
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
}
/// Pixels the content must be pushed down to clear the concave ear transparent area. /// 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. /// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
@@ -106,6 +102,13 @@ struct IslandContainer: View {
CountdownBar(state: state, islandW: islandWidth) 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 { Group {
if state.mode == .compact { if state.mode == .compact {
CompactMiniGrid(state: state) CompactMiniGrid(state: state)
@@ -151,6 +154,18 @@ struct IslandContainer: View {
guard state.mode == .expanded, state.view == .prompt else { return } guard state.mode == .expanded, state.view == .prompt else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight } 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 { .onAppear {
let (w, h) = islandSize(mode: state.mode, view: state.view, let (w, h) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
@@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
let cy: CGFloat let cy: CGFloat
if let fixedY = layout.botY { if let fixedY = layout.botY {
cy = fixedY 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 { } else {
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42) // Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
let headerBottom: CGFloat = 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 // MARK: - Countdown bar
struct CountdownBar: View { struct CountdownBar: View {
@@ -56,9 +56,11 @@ final class IslandWindowController: NSWindowController {
let nW = geometry.width let nW = geometry.width
let nH = geometry.height let nH = geometry.height
let panelW: CGFloat = 720 // Tall, transparent panel: the chat can grow down to the full screen height
let panelH: CGFloat = 320 // and widen; clicks outside the island still pass through (pollFrame).
let sf = screen.frame let sf = screen.frame
let panelW: CGFloat = 1100
let panelH: CGFloat = sf.height
let panel = IslandPanel( let panel = IslandPanel(
contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH, contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH,
width: panelW, height: panelH), width: panelW, height: panelH),
@@ -232,10 +234,12 @@ final class IslandWindowController: NSWindowController {
// in the app window immediately below the menu bar. // in the app window immediately below the menu bar.
let hoverRect = !hasNotch && state.mode != .expanded let hoverRect = !hasNotch && state.mode != .expanded
? islandRect : islandRect.insetBy(dx: -6, dy: -6) ? 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 // Toggle click-through
let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil
|| state.chatDragHeight != nil
if panel.ignoresMouseEvents == shouldAcceptMouse { if panel.ignoresMouseEvents == shouldAcceptMouse {
panel.ignoresMouseEvents = !shouldAcceptMouse panel.ignoresMouseEvents = !shouldAcceptMouse
if shouldAcceptMouse, let cv = panel.contentView { if shouldAcceptMouse, let cv = panel.contentView {
@@ -1015,12 +1019,10 @@ final class IslandWindowController: NSWindowController {
let panelW = window?.frame.width ?? 720 let panelW = window?.frame.width ?? 720
let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view, let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: notchW, nh: notchH) progress: s.uploadProgress, nw: notchW, nh: notchH)
// Chat view resizes dynamically — must match IslandContainer.chatPromptHeight // Chat view resizes dynamically — AppState.chatIslandHeight
let islandH: CGFloat let islandH: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view == .prompt {
let base: CGFloat = 240 islandH = s.chatIslandHeight
let perMsg: CGFloat = 40
islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
islandH = fixedH islandH = fixedH
} }
@@ -1083,9 +1085,7 @@ final class IslandPanel: NSPanel {
progress: s.uploadProgress, nw: nw, nh: nh) progress: s.uploadProgress, nw: nw, nh: nh)
let h: CGFloat let h: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view == .prompt {
let base: CGFloat = 240 h = s.chatIslandHeight
let perMsg: CGFloat = 40
h = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
h = fixedH h = fixedH
} }
@@ -1140,6 +1140,7 @@ extension Notification.Name {
// MARK: - islandSize (takes real notch dimensions) // MARK: - islandSize (takes real notch dimensions)
@MainActor
func islandSize(mode: IslandMode, view: IslandView, func islandSize(mode: IslandMode, view: IslandView,
progress: Double = 0, progress: Double = 0,
nw: CGFloat = IslandConst.notchWidth, nw: CGFloat = IslandConst.notchWidth,
@@ -1149,6 +1150,8 @@ func islandSize(mode: IslandMode, view: IslandView,
case .compact: return (nw + 160, nh) case .compact: return (nw + 160, nh)
case .expanded: case .expanded:
let layout = IslandConst.viewLayouts[view]! 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) return (IslandConst.expandedWidth, layout.height)
} }
} }
@@ -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) { Picker("Модель", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id) Text(m.label).tag(m.id)