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:
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user